#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# common.sh — Shared function library for all platform build scripts.
#
# Sourced by: build-pistorm.sh, build-classic.sh, build-mister.sh
#
# These functions are platform-agnostic. They operate on globals set by
# the calling platform script (e.g. $HST_IMAGER, $STAGE, $ASSETS_DIR,
# $SYS_ROOT, $AMINET_DIR, etc.).
# ---------------------------------------------------------------------------
# shellcheck disable=SC2034,SC2154  # globals set by caller

# ===========================================================================
# Core helpers
# ===========================================================================

# Locate the lha command. Checks (in order):
# 1. LHA_TOOL global (set by --lha argument from Swift)
# 2. App-managed install: ~/Library/Application Support/Amiga-Imager/Tools/lha
# 3. PATH, /usr/local/bin/lha, /opt/homebrew/bin/lha
# Echoes the path and returns 0, or returns 1 if not found.
find_lha() {
  # Explicit override from Swift
  if [[ -n "${LHA_TOOL:-}" && -x "$LHA_TOOL" ]]; then
    printf "%s" "$LHA_TOOL"
    return 0
  fi
  # App-managed install
  local _app_lha="$HOME/Library/Application Support/Amiga-Imager/Tools/lha"
  if [[ -x "$_app_lha" ]]; then
    printf "%s" "$_app_lha"
    return 0
  fi
  # System PATH and common locations
  for _l in lha /usr/local/bin/lha /opt/homebrew/bin/lha; do
    if command -v "$_l" >/dev/null 2>&1; then
      printf "%s" "$_l"
      return 0
    fi
    if [[ -x "$_l" ]]; then
      printf "%s" "$_l"
      return 0
    fi
  done
  return 1
}
die() { echo "ERROR: $*" >&2; exit 1; }
run() { echo "+ $*"; "$@"; }
have_cmd() { command -v "$1" >/dev/null 2>&1; }

# Common scripts treat download-only as opt-in; default to normal builds when unset.
: "${DOWNLOAD_ONLY:=0}"

# pkg_time_start / pkg_time_done — per-phase timing using the bash SECONDS builtin.
# Usage:  pkg_time_start; <work>; pkg_time_done "label"
_PKG_TIMER_START=0
pkg_time_start() { _PKG_TIMER_START=$SECONDS; }
pkg_time_done()  { echo "==> [timer] $1: $(( SECONDS - _PKG_TIMER_START ))s"; }

# ── Build-plan state (Phase 2B) ───────────────────────────────────────────────
# Bash 3.2-safe indexed arrays. Rows share the same numeric index.
_BPLAN_LOADED=0
_BPLAN_IDS=()
_BPLAN_STRATEGY=()
_BPLAN_TOUCHED=()
_BPLAN_ACTIONS=()
_BPLAN_CPUMIN=()
_BPLAN_ARCHIVE=()

_bplan_reset() {
    _BPLAN_LOADED=0
    _BPLAN_IDS=()
    _BPLAN_STRATEGY=()
    _BPLAN_TOUCHED=()
    _BPLAN_ACTIONS=()
    _BPLAN_CPUMIN=()
    _BPLAN_ARCHIVE=()
}

_bplan_index_of() {
    local want="$1" i
    for ((i = 0; i < ${#_BPLAN_IDS[@]}; i++)); do
        [[ "${_BPLAN_IDS[$i]}" == "$want" ]] && { printf '%s' "$i"; return 0; }
    done
    return 1
}

_run_legacy_post_actions() {
    local sys_root="$1" pkg_id="$2"
    case "$pkg_id" in
        scummvm)
            _set_borderless_image_file "$sys_root" "Programs/ScummVM.info"
            _set_borderless_image_file "$sys_root" "Programs/ScummVM/ScummVM.info"
            ;;
        ibrowse)
            _set_borderless_image_file "$sys_root" "Programs/Ibrowse.info"
            _set_borderless_image_file "$sys_root" "Programs/Ibrowse/IBrowse.info"
            ;;
        wbdock)
            _set_borderless_image_file "$sys_root" "Programs/WBDock.info"
            _set_borderless_image_file "$sys_root" "Programs/WBDock/WBDock.info"
            ;;
    esac
}

_list_contains_exact() {
    local want="$1"
    shift || true
    local item
    for item in "$@"; do
        [[ "$item" == "$want" ]] && return 0
    done
    return 1
}

normalize_icon_pass2_mode() {
    local raw
    raw="$(trim_spaces "${1:-scoped}")"
    raw="$(echo "$raw" | tr '[:upper:]' '[:lower:]')"
    case "$raw" in
        scoped|global) echo "$raw" ;;
        *) echo "scoped" ;;
    esac
}

# load_build_plan <tsv-path>
# Reads the compiled execution plan written by BuildScriptArgumentBuilder.writeShellPlan().
# If no plan path is provided, the build falls back to legacy hardcoded post-actions.
# If a plan path is provided but missing, fail fast — the app generated an inconsistent run.
load_build_plan() {
    _bplan_reset
    local plan="${1:-}"
    [[ -z "$plan" ]] && return 0
    [[ -f "$plan" ]] || die "Build plan missing: $plan"

    # archive (v2): staged archive basename for user-defined packages; "-" otherwise.
    # v1 plans simply leave the field empty.
    local id strategy touched actions cpu _auto _reason archive
    while IFS=$'\t' read -r id strategy touched actions cpu _auto _reason archive; do
        [[ "$id" =~ ^# ]] && continue
        [[ -z "$id" ]] && continue
        _BPLAN_IDS+=("$id")
        _BPLAN_STRATEGY+=("$strategy")
        _BPLAN_TOUCHED+=("$touched")
        _BPLAN_ACTIONS+=("$actions")
        _BPLAN_CPUMIN+=("$cpu")
        _BPLAN_ARCHIVE+=("${archive:--}")
    done < "$plan"
    _BPLAN_LOADED=1
}

_bplan_collect_touched_roots() {
    local touched root
    local roots=()
    local roots_count=0
    local _bptouched=()
    for touched in "${_BPLAN_TOUCHED[@]}"; do
        [[ -z "$touched" || "$touched" == "-" ]] && continue
        IFS='|' read -r -a _bptouched <<< "$touched"
        for root in "${_bptouched[@]}"; do
            root="$(normalize_rel_path "$root")"
            [[ -z "$root" ]] && continue
            if [[ "$roots_count" -eq 0 ]] || ! _list_contains_exact "$root" "${roots[@]}"; then
                roots+=("$root")
                roots_count=$((roots_count + 1))
            fi
        done
    done

    local out
    for out in "${roots[@]}"; do
        printf "%s\n" "$out"
    done
}

_icon_row_in_scope() {
    local rel_path="$1"
    shift || true
    rel_path="$(normalize_rel_path "$rel_path")"
    [[ -z "$rel_path" ]] && return 1

    local scope
    for scope in "$@"; do
        scope="$(normalize_rel_path "$scope")"
        [[ -z "$scope" ]] && continue
        if [[ "$rel_path" == "$scope" || "$rel_path" == "$scope/"* ]]; then
            return 0
        fi
    done
    return 1
}

# run_plan_post_actions <sys_root> <package_id>
# Executes post-install actions declared in the build plan for <package_id>.
# Handles "borderless-icon:<path>" entries. Other action types (startup-inject,
# scummvm-register) are still executed by their dedicated caller functions.
run_plan_post_actions() {
    local sys_root="$1" pkg_id="$2"
    if [[ "${_BPLAN_LOADED:-0}" -ne 1 ]]; then
        _run_legacy_post_actions "$sys_root" "$pkg_id"
        return 0
    fi

    local idx
    idx="$(_bplan_index_of "$pkg_id")" || return 0
    local actions="${_BPLAN_ACTIONS[$idx]:--}"
    [[ "$actions" == "-" ]] && return 0
    local _bpa atype aarg
    IFS='|' read -r -a _bpa <<< "$actions"
    for a in "${_bpa[@]}"; do
        atype="${a%%:*}"
        aarg="${a#*:}"
        case "$atype" in
            borderless-icon) _set_borderless_image_file "$sys_root" "$aarg" ;;
        esac
    done
}

# sweep_plan_post_actions <sys_root>
# Runs run_plan_post_actions for every package id present in the loaded plan.
# Called once after all packages are installed as a safety net — catches any
# package whose post-install actions were not yet executed (e.g. newly added
# archive-map packages that gain post-install actions before their per-package
# call site is updated). No-op if no plan was loaded.
sweep_plan_post_actions() {
    local sys_root="$1"
    [[ "${_BPLAN_LOADED:-0}" -eq 1 ]] || return 0
    local pkg_id
    for pkg_id in "${_BPLAN_IDS[@]}"; do
        run_plan_post_actions "$sys_root" "$pkg_id"
    done
}

# ── Strategy handler wrappers (Phase 3) ──────────────────────────────────────
# Thin wrappers that enforce: timing, platform-agnostic gate check, and
# plan-driven post-install actions. Platform scripts pass platform-specific args.

# install_pkg_ibrowse <sys_root> <ibrowse_dir> [<key_file>] [<js_variant>]
# Gates on INCLUDE_IBROWSE + ibrowse_dir existence. Post-install via plan.
install_pkg_ibrowse() {
    local sys_root="$1" ibrowse_dir="$2" key="${3:-}" js_variant="${4:-040fpu}"
    [[ "${INCLUDE_IBROWSE:-0}" -eq 1 && -n "$ibrowse_dir" && -d "$ibrowse_dir" ]] || return 0
    pkg_time_start
    install_ibrowse_from_official "$sys_root" "$ibrowse_dir" "$key" "$js_variant"
    pkg_time_done "install_ibrowse"
    run_plan_post_actions "$sys_root" "ibrowse"
}

# install_pkg_wbdock <sys_root> <ibrowse_dir>
# Gates on INCLUDE_WBDOCK + ibrowse_dir existence. Post-install via plan.
install_pkg_wbdock() {
    local sys_root="$1" ibrowse_dir="$2"
    [[ "${INCLUDE_WBDOCK:-0}" -eq 1 && -n "$ibrowse_dir" && -d "$ibrowse_dir" ]] || return 0
    pkg_time_start
    install_wbdock "$sys_root" "$ibrowse_dir"
    pkg_time_done "install_wbdock"
    inject_wbdock_into_startup "$sys_root"
    run_plan_post_actions "$sys_root" "wbdock"
}

# run_scummvm_game_pkg <game_id> <sys_root>
# Installs one ScummVM freeware game (bass/fotaq/lure) with timing + plan actions.
run_scummvm_game_pkg() {
    local game_id="$1" sys_root="$2"
    package_is_selected "$game_id" || return 0
    pkg_time_start
    "install_${game_id}" "$sys_root"
    pkg_time_done "install_${game_id}"
    run_plan_post_actions "$sys_root" "$game_id"
}

# is_scummvm_archive <lha_basename>
# Returns 0 if the filename matches a known ScummVM archive variant.
is_scummvm_archive() { echo "$1" | grep -Eqi "^ScummVM_AGA_060\.lha$|^ScummVM_RTG_060\.lha$|^scummvm-.*-rtg\.lha$"; }

# is_amigaamp_archive <lha_basename>
# Returns 0 if the filename matches a known AmigaAMP archive variant.
is_amigaamp_archive() { echo "$1" | grep -Eqi "^AmigaAMP\.lha$|^AmigaAMP3-68k\.lha$"; }

# install_pkg_scummvm <sys_root> <lha_file> <lha_base> <cpu_class>
# Wrapper called from the Aminet loop when a ScummVM archive is detected.
# Handles timing, AMINET_BUILDTIME tracking, and plan actions.
# On success returns 0 and outputs the lha_base to be added to AMINET_BUILDTIME.
# The caller is responsible for adding lha_base to AMINET_BUILDTIME and continuing.
install_pkg_scummvm() {
    local sys_root="$1" lha_file="$2" lha_base="$3" cpu_class="${4:-060}"
    echo "  -> processing Aminet: $lha_base (ScummVM install, cpu_class=$cpu_class)"
    pkg_time_start
    if install_scummvm "$sys_root" "$lha_file" "$cpu_class"; then
        pkg_time_done "install_scummvm"
        run_plan_post_actions "$sys_root" "scummvm"
        return 0
    fi
    return 1
}

# install_pkg_amigaamp <sys_root> <lha_file> <lha_base>
# Installs AmigaAMP using explicit archive root detection instead of CSV wildcard rules.
install_pkg_amigaamp() {
    local sys_root="$1" lha_file="$2" lha_base="$3"
    local tmp_extract="$STAGE/amigaamp-extract-${lha_base%.*}"
    local app_root="" root_icon=""

    echo "  -> processing Aminet: $lha_base (AmigaAMP explicit install)"
    pkg_time_start

    rm -rf "$tmp_extract" 2>/dev/null || true
    mkdir -p "$tmp_extract"
    if ! extract_lha_to_host "$lha_file" "$tmp_extract"; then
        echo "==> WARN: Failed to extract $lha_base for explicit AmigaAMP install"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
    fi

    app_root="$(find "$tmp_extract" -maxdepth 1 -type d \( -iname "AmigaAMP3" -o -iname "AmigaAMP" \) | head -1)"
    if [[ -z "$app_root" || ! -d "$app_root" ]]; then
        echo "==> WARN: Could not locate AmigaAMP app root inside $lha_base"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
    fi

    fs_copy_tree_filtered "$app_root" "$sys_root/Programs/AmigaAMP"

    # Verify the binary actually landed — fs_copy_tree_filtered always returns 0,
    # so a silent hst-imager failure would otherwise look like success.
    local _amp_bin=""
    _amp_bin="$(find "$app_root" -maxdepth 1 -type f \( -iname "AmigaAMP3" -o -iname "AmigaAMP" \) ! -iname "*.info" | head -1)"
    local _amp_bin_name=""
    [[ -n "$_amp_bin" ]] && _amp_bin_name="$(basename "$_amp_bin")"
    # Expose binary name globally so install_wbdock can use the correct dock path.
    _AMIGAAMP_BIN_NAME="$_amp_bin_name"
    if [[ -n "$_amp_bin_name" ]] && ! fs_dir "$sys_root/Programs/AmigaAMP/$_amp_bin_name"; then
        echo "  -> fs_copy_tree_filtered missed AmigaAMP; retrying with fs_copy"
        fs_copy "$app_root/" "$sys_root/Programs/AmigaAMP" --recursive --makedir || \
            fs_copy "$app_root" "$sys_root/Programs/AmigaAMP" --recursive --makedir
    fi

    root_icon="$(find "$tmp_extract" -maxdepth 1 -type f \( -iname "AmigaAMP3.info" -o -iname "AmigaAMP.info" \) | head -1)"
    if [[ -n "$root_icon" && -f "$root_icon" ]]; then
        fs_copy_file "$root_icon" "$sys_root/Programs/AmigaAMP.info" || true
    fi

    rm -rf "$tmp_extract" 2>/dev/null || true
    pkg_time_done "install_amigaamp"
    run_plan_post_actions "$sys_root" "amigaamp"
    return 0
}

# is_rcftpd_archive <lha_basename>
# Returns 0 if the filename matches a known rc-ftpd archive variant.
is_rcftpd_archive() { echo "$1" | grep -Eqi "^rc-ftpd\.lha$"; }

# install_pkg_rcftpd <sys_root> <lha_file> <lha_base>
# Installs rc-ftpd FTP server: Programs/RCFTPd/, taglist.library → Libs/,
# and ENVARC prefs icon → Prefs/Env-Archive/Sys/.
install_pkg_rcftpd() {
    local sys_root="$1" lha_file="$2" lha_base="$3"
    local tmp_extract="$STAGE/rcftpd-extract-${lha_base%.*}"
    local app_root="" root_icon=""

    echo "  -> processing Aminet: $lha_base (rc-ftpd explicit install)"
    pkg_time_start

    rm -rf "$tmp_extract" 2>/dev/null || true
    mkdir -p "$tmp_extract"
    if ! extract_lha_to_host "$lha_file" "$tmp_extract"; then
        echo "==> WARN: Failed to extract $lha_base for rc-ftpd install"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
    fi

    app_root="$(find "$tmp_extract" -maxdepth 1 -type d -iname "RCFTPd" | head -1)"
    if [[ -z "$app_root" || ! -d "$app_root" ]]; then
        echo "==> WARN: Could not locate RCFTPd app root inside $lha_base"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
    fi

    # Main program drawer
    fs_copy_tree_filtered "$app_root" "$sys_root/Programs/RCFTPd"

    # Verify binary landed
    if ! fs_dir "$sys_root/Programs/RCFTPd/ftpd"; then
        echo "  -> fs_copy_tree_filtered missed RCFTPd; retrying with fs_copy"
        fs_copy "$app_root/" "$sys_root/Programs/RCFTPd" --recursive --makedir || \
            fs_copy "$app_root" "$sys_root/Programs/RCFTPd" --recursive --makedir
    fi

    # Top-level drawer icon
    root_icon="$(find "$tmp_extract" -maxdepth 1 -type f -iname "RCFTPd.info" | head -1)"
    if [[ -n "$root_icon" && -f "$root_icon" ]]; then
        fs_copy_file "$root_icon" "$sys_root/Programs/RCFTPd.info" || true
    fi

    # taglist.library → Libs/
    local taglist="$app_root/libs/taglist.library"
    if [[ -f "$taglist" ]]; then
        fs_copy_file "$taglist" "$sys_root/Libs/taglist.library" || true
    fi

    # ENVARC prefs icon → Prefs/Env-Archive/Sys/
    local envarc_icon="$app_root/ENVARC/Sys/def_rc-ftpd.info"
    if [[ -f "$envarc_icon" ]]; then
        fs_copy_file "$envarc_icon" "$sys_root/Prefs/Env-Archive/Sys/def_rc-ftpd.info" || true
    fi

    rm -rf "$tmp_extract" 2>/dev/null || true
    pkg_time_done "install_rcftpd"
    run_plan_post_actions "$sys_root" "rcftpd"
    return 0
}

# is_govd_archive <lha_basename>
# Returns 0 if the filename matches a known GoVD archive variant.
is_govd_archive() { echo "$1" | grep -Eqi "^GoVD\.lha$"; }

# install_govd_key_file <sys_root> <key_file>
# Copy the GoVD registration key to SYS:l/ (L: assign).
install_govd_key_file() {
  local sys_root="$1"
  local key_file="${2:-}"

  [[ -n "$key_file" && -f "$key_file" ]] || return 0

  local key_name
  key_name="$(basename "$key_file")"
  echo "  -> Installing GoVD key file: $key_name → SYS:l/"
  fs_copy_file "$key_file" "$sys_root/l/$key_name"
}

# install_pkg_govd <sys_root> <lha_file> <lha_base> [<key_file>]
# Installs GoVD using explicit archive extraction.
install_pkg_govd() {
    local sys_root="$1" lha_file="$2" lha_base="$3" key_file="${4:-}"
    local tmp_extract="$STAGE/govd-extract-${lha_base%.*}"
    local app_root="" root_icon=""

    echo "  -> processing Aminet: $lha_base (GoVD explicit install)"
    pkg_time_start

    rm -rf "$tmp_extract" 2>/dev/null || true
    mkdir -p "$tmp_extract"
    if ! extract_lha_to_host "$lha_file" "$tmp_extract"; then
        echo "==> WARN: Failed to extract $lha_base for GoVD install"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
    fi

    app_root="$(find "$tmp_extract" -maxdepth 1 -type d -iname "GoVD" | head -1)"
    if [[ -z "$app_root" || ! -d "$app_root" ]]; then
        echo "==> WARN: Could not locate GoVD app root inside $lha_base"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
    fi

    fs_copy_tree_filtered "$app_root" "$sys_root/Programs/GoVD"

    # Verify binary landed
    local _govd_bin=""
    _govd_bin="$(find "$app_root" -maxdepth 1 -type f -iname "GoVD" ! -iname "*.info" | head -1)"
    local _govd_bin_name=""
    [[ -n "$_govd_bin" ]] && _govd_bin_name="$(basename "$_govd_bin")"
    if [[ -n "$_govd_bin_name" ]] && ! fs_dir "$sys_root/Programs/GoVD/$_govd_bin_name"; then
        echo "  -> fs_copy_tree_filtered missed GoVD; retrying with fs_copy"
        fs_copy "$app_root/" "$sys_root/Programs/GoVD" --recursive --makedir || \
            fs_copy "$app_root" "$sys_root/Programs/GoVD" --recursive --makedir
    fi

    root_icon="$(find "$tmp_extract" -maxdepth 1 -type f -iname "GoVD.info" | head -1)"
    if [[ -n "$root_icon" && -f "$root_icon" ]]; then
        fs_copy_file "$root_icon" "$sys_root/Programs/GoVD.info" || true
    fi

    rm -rf "$tmp_extract" 2>/dev/null || true
    pkg_time_done "install_govd"

    install_govd_key_file "$sys_root" "$key_file"
    run_plan_post_actions "$sys_root" "govd"
    return 0
}


# is_dedicated_external_archive <lha_basename>
#   Archives consumed by dedicated installers (IBrowse, AmiSSL, WBDock).
#   The unified --packages-dir stages them next to Aminet archives; the
#   generic Aminet loop must skip them so they are not reported as unmapped.
is_dedicated_external_archive() {
  echo "$1" | grep -Eqi "^IBrowse[0-9].*\.lha$|^AmiSSL-.*\.lha$|^wbdock2\.lha$"
}

# install_pkg_user_archive <sys_root> <lha_file> <lha_base>
#   Installs a user-defined package (added in Settings by download URL).
#   Matches the archive basename against the build plan's archive column
#   (strategy archive-tree) and extracts into the plan's touched path
#   (Programs/<Name>). Returns 1 when the archive is not a user package.
install_pkg_user_archive() {
  local sys_root="$1" lha_file="$2" lha_base="$3"
  [[ "${_BPLAN_LOADED:-0}" -eq 1 ]] || return 1
  local i want have dest app
  want="$(printf '%s' "$lha_base" | tr '[:upper:]' '[:lower:]')"
  for ((i = 0; i < ${#_BPLAN_IDS[@]}; i++)); do
    have="${_BPLAN_ARCHIVE[$i]:--}"
    [[ "$have" == "-" || -z "$have" ]] && continue
    [[ "$(printf '%s' "$have" | tr '[:upper:]' '[:lower:]')" == "$want" ]] || continue
    [[ "${_BPLAN_STRATEGY[$i]}" == "archive-tree" ]] || continue
    dest="${_BPLAN_TOUCHED[$i]%%|*}"
    if [[ -z "$dest" || "$dest" == "-" ]]; then
      dest="Programs/${lha_base%.*}"
    fi
    app="$(basename "$dest")"
    echo "  -> processing user package: $lha_base (-> $dest)"
    _install_pkg_simple_app "$sys_root" "$lha_file" "$lha_base" "$app" "$dest" "${_BPLAN_IDS[$i]}"
    return $?
  done
  return 1
}

# is_solas_archive <lha_basename>
is_solas_archive() { echo "$1" | grep -Eqi "^Solas\.lha$"; }

# is_lumiweather_archive <lha_basename>
is_lumiweather_archive() { echo "$1" | grep -Eqi "^LumiWeather_[0-9]"; }

# is_lumipass_archive <lha_basename>
is_lumipass_archive() { echo "$1" | grep -Eqi "^LumiPass_[0-9]"; }

# is_lumireg_archive <lha_basename>
is_lumireg_archive() { echo "$1" | grep -Eqi "^LumiReg_[0-9]"; }

# _default_drawer_icon <sys_root>
#   Pull the installed Workbench default drawer icon once per build and cache
#   it host-side (type 2, no position so Workbench auto-places). Prints the
#   host path; returns 1 when the Workbench prefs icon is unavailable.
_default_drawer_icon() {
  local sys_root="$1"
  local cache="$STAGE/def_drawer_proto.info"
  if [[ ! -f "$cache" ]]; then
    fs_pull_file "$sys_root/Prefs/Env-Archive/Sys/def_drawer.info" "$cache" >/dev/null 2>&1 || return 1
    "$AMIGA_TOOLS" icon-update "$cache" --type 2 --current-x -2147483648 --current-y -2147483648 2>/dev/null || true
  fi
  echo "$cache"
}

# _install_pkg_simple_app <sys_root> <lha_file> <lha_base> <app_name> <dest_path> <pkg_id>
#   Generic LHA extractor: finds <app_name>/ subdir or uses root, copies to <dest_path>.
_install_pkg_simple_app() {
    local sys_root="$1" lha_file="$2" lha_base="$3" app_name="$4" dest_path="$5" pkg_id="$6"
    local tmp_extract="$STAGE/${pkg_id}-extract-${lha_base%.*}"
    local app_root=""

    echo "  -> processing Aminet: $lha_base ($app_name install)"
    pkg_time_start

    rm -rf "$tmp_extract" 2>/dev/null || true
    mkdir -p "$tmp_extract"
    if ! extract_lha_to_host "$lha_file" "$tmp_extract"; then
        echo "==> WARN: Failed to extract $lha_base for $app_name install"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
    fi

    app_root="$(find "$tmp_extract" -maxdepth 1 -type d -iname "$app_name" | head -1)"
    [[ -z "$app_root" || ! -d "$app_root" ]] && app_root="$tmp_extract"

    fs_mkdir_p "$sys_root/$dest_path"
    fs_copy_tree_filtered "$app_root" "$sys_root/$dest_path"

    # Standard Workbench drawer icon for the Programs/<App> drawer. Archive
    # root icons are usually the binary's TOOL icon — confusing as a folder.
    local root_icon=""
    root_icon="$(_default_drawer_icon "$sys_root" || true)"
    if [[ -z "$root_icon" ]]; then
        # Fallback: archive root icon converted to drawer type
        root_icon="$(find "$tmp_extract" -maxdepth 1 -type f -iname "${app_name}.info" | head -1)"
        if [[ -n "$root_icon" ]]; then
            "$AMIGA_TOOLS" icon-update "$root_icon" --type 2 2>/dev/null || true
        fi
    fi
    if [[ -n "$root_icon" && -f "$root_icon" ]]; then
        local parent_dir
        parent_dir="$(dirname "$dest_path")"
        fs_copy_file "$root_icon" "$sys_root/$parent_dir/${app_name}.info" || true
    fi

    rm -rf "$tmp_extract" 2>/dev/null || true
    pkg_time_done "install_${pkg_id}"
    run_plan_post_actions "$sys_root" "$pkg_id"
    return 0
}

install_pkg_solas()       { _install_pkg_simple_app "$1" "$2" "$3" "Solas"       "Programs/Solas"       "solas"; }
install_pkg_lumiweather() { _install_pkg_simple_app "$1" "$2" "$3" "LumiWeather" "Programs/LumiWeather" "lumiweather"; }
install_pkg_lumipass()    { _install_pkg_simple_app "$1" "$2" "$3" "LumiPass"    "Programs/LumiPass"    "lumipass"; }
install_pkg_lumireg()     { _install_pkg_simple_app "$1" "$2" "$3" "LumiReg"     "Programs/LumiReg"     "lumireg"; }

# normalize_unpositioned_program_icons <sys_root>
#   Workbench tidy-up for the SYS:Programs window. Icons without a curated
#   row in IconPositions.csv keep whatever coordinates their archive author
#   saved, so they pile up on top of each other. Reset those to
#   NO_ICON_POSITION (0x80000000) and Workbench auto-arranges them cleanly.
#   Curated icons keep their CSV positions. Run after the pass-2 positioning.
#   Native disk engine only (listing support); legacy hst path skips.
normalize_unpositioned_program_icons() {
  local sys_root="$1"
  [[ "$DISK_ENGINE" == "amigadiskkit" ]] || return 0
  _disk_parse_path "$sys_root/Programs"
  [[ -n "$_dp_part" ]] || return 0

  # Normalized names of top-level Programs/<Name> entries curated by the CSV
  # (all rows, regardless of Include/version filters — never auto-move icons
  # the CSV knows about).
  local csv_path curated=""
  csv_path="$(find_input_csv "IconPositions.CSV" || true)"
  if [[ -n "$csv_path" ]]; then
    curated="$(awk -F';' '
      function trim(s){ gsub(/\r/, "", s); sub(/^[ \t"]+/, "", s); sub(/[ \t"]+$/, "", s); return s }
      function norm(s){ s=tolower(trim(s)); gsub(/[^a-z0-9]/, "", s); return s }
      function get(k,   idx,val){ idx=h[k]; if (idx == 0) return ""; val=trim($(idx)); return val }
      NR==1{ for(i=1;i<=NF;i++) h[norm($i)] = i; next }
      {
        f=get("file"); if (f == "") next
        sub(/^[^:]*:/, "", f)
        gsub(/\\\\/, "/", f)
        if (tolower(f) !~ /^programs\//) next
        sub(/^[Pp][Rr][Oo][Gg][Rr][Aa][Mm][Ss]\//, "", f)
        if (f ~ /\//) next
        sub(/\.[Ii][Nn][Ff][Oo]$/, "", f)
        print norm(f)
      }
    ' "$csv_path" | sort -u)"
  fi

  local _name _norm _tmp_icon reset_count=0
  _tmp_icon="$STAGE/progicon-reset.$$.info"
  while IFS= read -r _name; do
    [[ "$_name" == *.info ]] || continue
    _norm="$(printf '%s' "${_name%.info}" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9')"
    [[ -n "$_norm" ]] || continue
    if [[ -n "$curated" ]] && printf '%s\n' "$curated" | grep -qx "$_norm"; then
      continue
    fi
    fs_pull_file "$sys_root/Programs/$_name" "$_tmp_icon" >/dev/null 2>&1 || continue
    if "$AMIGA_TOOLS" icon-update "$_tmp_icon" --current-x -2147483648 --current-y -2147483648 2>/dev/null; then
      if fs_copy_file "$_tmp_icon" "$sys_root/Programs/$_name"; then
        reset_count=$((reset_count + 1))
      fi
    fi
    rm -f "$_tmp_icon" 2>/dev/null || true
  done < <("$AMIGA_TOOLS" disk fs dir "$DISK_ENGINE_IMG" "$_dp_part" Programs 2>/dev/null \
            | awk '$1=="FILE"{ $1=""; $2=""; sub(/^ +/, ""); print }')

  echo "==> Programs window: $reset_count icon(s) without curated positions set to auto-arrange"
}

# classic_is_68020plus()
#   Prints "1" if the Classic target has a 68020 or higher CPU, "0" otherwise.
#   Reads $CLASSIC_MACHINE and $ACCELERATOR from the calling script's environment.
#   Stock 68000 machines: A500, A600, A2000. Everything else ships with >=68EC020.
#   Any non-"none" accelerator guarantees >=68020 regardless of base machine.
#   Mirrors Roadshow's own Install_Roadshow CPU check
#   (#local-cpu-ok = not in {"68000","68010"}).
classic_is_68020plus() {
  local accel="${ACCELERATOR:-none}"
  if [[ -n "$accel" && "$accel" != "none" ]]; then
    printf '1'
    return 0
  fi
  # Known 020+ base machines. Unknown or empty falls through to the safe
  # "0" default (root variants are 68000-compat and run on every Amiga CPU).
  case "${CLASSIC_MACHINE:-}" in
    A1200|A3000|A4000|A4000T|CD32) printf '1' ;;
    *)                             printf '0' ;;
  esac
}

# classic_is_68040plus()
#   Prints "1" if the Classic target has a 68040 or higher CPU, "0" otherwise.
#   Used for packages requiring 68040+ (mpega040 variants, RiVA video player).
#   Hardware 68040/060 accelerators always have an FPU; EC040 is not a user-selectable
#   accelerator option so we treat all 68040+ accelerators as having FPU.
classic_is_68040plus() {
  local accel="${ACCELERATOR:-none}"
  case "$accel" in
    68040|68060) printf '1'; return 0 ;;
  esac
  # A4000T shipped with 68040 stock; A4000 could be 68030 or 68040 but we can't
  # determine from machine type alone without an explicit accelerator selection.
  case "${CLASSIC_MACHINE:-}" in
    A4000T) printf '1' ;;
    *)      printf '0' ;;
  esac
}

# Escape characters special inside double-quoted strings (for config file generation).
escape_for_dquote() {
  printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\$/\\$/g; s/`/\\`/g'
}

export_debug_hdf() {
  [[ "${DEBUG:-0}" -eq 1 ]] || return 0

  local outdir base out rdb_slice
  outdir="${DEBUG_EXPORT_DIR:-$(dirname "$IMG")}"
  base="$(basename "$IMG")"
  base="${base%.*}"
  out="$outdir/${base}.hdf"
  rdb_slice="$IMG/mbr/2"

  mkdir -p "$outdir" >/dev/null 2>&1 || true

  if [[ "${RDB:-}" == "$IMG/mbr/"* ]]; then
    echo "==> DEBUG: exporting Amiga HDF (MBR partition 2 / RDB slice) to: $out" >&2

    # Try both common syntaxes (hst-imager variants differ)
    if "$HST_IMAGER" mbr part export "$IMG" 2 "$out" >/dev/null 2>&1; then
      echo "==> DEBUG: exported via: hst-imager mbr part export $IMG 2" >&2
      return 0
    fi
    if "$HST_IMAGER" mbr part export "$IMG/mbr" 2 "$out" >/dev/null 2>&1; then
      echo "==> DEBUG: exported via: hst-imager mbr part export $IMG/mbr 2" >&2
      return 0
    fi

    # Fallback: the RDB slice is already present as a raw file under $IMG/mbr/2
    if [[ -f "$rdb_slice" ]]; then
      cp -f "$rdb_slice" "$out" >/dev/null 2>&1 || true
      echo "==> DEBUG: exported via fallback copy of $rdb_slice" >&2
      return 0
    fi

    echo "==> DEBUG: WARN: could not export HDF (no export command + missing $rdb_slice)" >&2
    return 0
  fi

  echo "==> DEBUG: exporting pure RDB image to: $out" >&2
  if [[ -f "$IMG" ]] && cp -f "$IMG" "$out" >/dev/null 2>&1; then
    echo "==> DEBUG: exported via raw image copy of $IMG" >&2
    return 0
  fi

  echo "==> DEBUG: WARN: could not export pure RDB HDF from $IMG" >&2
  return 0
}

# ===========================================================================
# Disk engine dispatch
# DISK_ENGINE=hst (default) — use HST_IMAGER for all image FS operations.
# DISK_ENGINE=amigadiskkit  — use AMIGA_TOOLS disk fs (native Swift path).
# Revert: unset DISK_ENGINE or set DISK_ENGINE=hst; no other code change needed.
#
# Platform scripts populate these after partition creation:
#   DISK_ENGINE_IMG         — raw image file path
#   DISK_ENGINE_ROOT_BASE   — PARTITION_ROOT_BASE value
#   DISK_ENGINE_PART_NAMES  — indexed array: partnum → partition name
# ===========================================================================
DISK_ENGINE="${DISK_ENGINE:-hst}"
DISK_ENGINE_IMG=""
DISK_ENGINE_ROOT_BASE=""
DISK_ENGINE_PART_NAMES=()
DISK_ENGINE_FAT_ROOT=""   # set by PiStorm build to $FAT_ROOT when DISK_ENGINE=amigadiskkit

_fat_parse_path() {
  # Extracts the FAT-relative path from a full virtual FAT path like "$IMG/mbr/1/config.txt".
  # Sets _fp_rel to the relative path (e.g. "/config.txt", "/") or "" if not a FAT path.
  # Always returns 0.
  _fp_rel=""
  [[ -n "$DISK_ENGINE_FAT_ROOT" ]] || return 0
  local _full="${1%/}"                       # strip trailing slash for comparison
  local _fat_root="${DISK_ENGINE_FAT_ROOT%/}"
  if [[ "$_full" == "$_fat_root" ]]; then
    _fp_rel="/"
  elif [[ "$_full" == "$_fat_root/"* ]]; then
    _fp_rel="/${_full#${_fat_root}/}"
  fi
}

_disk_parse_path() {
  # Splits a hst-imager-style image path into partition name + amiga sub-path.
  # Sets _dp_part (partition name) and _dp_path (amiga path, may be empty).
  # Always returns 0 — caller must not rely on exit code for dispatch logic.
  _dp_part=""; _dp_path=""
  [[ -n "$DISK_ENGINE_ROOT_BASE" ]] || return 0
  local _full="$1"
  local _rest="${_full#${DISK_ENGINE_ROOT_BASE}/}"
  [[ "$_rest" != "$_full" ]] || return 0         # prefix not found — not an RDB path
  local _pnum="${_rest%%/*}"
  _dp_part="${DISK_ENGINE_PART_NAMES[$_pnum]:-}"
  if [[ "$_rest" == */* ]]; then _dp_path="${_rest#*/}"; fi
}

# ===========================================================================
# Filesystem helpers (hst-imager wrappers — dispatched by DISK_ENGINE)
# ===========================================================================
fs_mkdir_p() {
  # best-effort mkdir (ignore errors if already exists)
  local path="$1"
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$path"
    if [[ -n "$_fp_rel" ]]; then
      "$AMIGA_TOOLS" disk fat mkdir "$DISK_ENGINE_IMG" "$_fp_rel" >/dev/null 2>&1 || true
      return 0
    fi
    _disk_parse_path "$path"
    if [[ -n "$_dp_part" && -n "$_dp_path" ]]; then
      "$AMIGA_TOOLS" disk fs mkdir "$DISK_ENGINE_IMG" "$_dp_part" "$_dp_path" >/dev/null 2>&1 || true
      return 0
    fi
  fi
  "$HST_IMAGER" fs mkdir "$path" >/dev/null 2>&1 || true
}

fs_copy_file_no_makedir() {
  # Copy a single file; NEVER pass --makedir here (it can turn the destination into a directory)
  local src="$1"
  local dst="$2"
  local parent
  parent="$(dirname "$dst")"
  ensure_img_dir "$parent"
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$dst"
    if [[ -n "$_fp_rel" ]]; then
      "$AMIGA_TOOLS" disk fat copy "$DISK_ENGINE_IMG" "$src" "$_fp_rel"
      return
    fi
    _disk_parse_path "$dst"
    if [[ -n "$_dp_part" && -n "$_dp_path" ]]; then
      "$AMIGA_TOOLS" disk fs copy "$DISK_ENGINE_IMG" "$_dp_part" "$src" "$_dp_path"
      return
    fi
  fi
  "$HST_IMAGER" fs copy "$src" "$dst" --force
}


stage_onetimerunwb_payload_filtered() {
  # Stage SYS:S/OneTimeRunWB payload from Assets.
  # Uses a single hst-imager --recursive call for speed.
  local src_dir="$1"   # assets source folder
  local dst_dir="$2"   # image destination folder

  [[ -d "$src_dir" ]] || return 0

  echo "==> Stage OneTimeRunWB payload (filtered) from: $src_dir"

  fs_mkdir_p "$dst_dir"

  # Strip macOS metadata into a temp copy then do a single recursive copy.
  local clean_src="$STAGE/.otrwb_clean_$$.$RANDOM"
  cp -a "$src_dir/." "$clean_src"
  find "$clean_src" -name '.DS_Store' -delete 2>/dev/null || true
  find "$clean_src" -name '._*' -delete 2>/dev/null || true
  find "$clean_src" -type d -name '__MACOSX' -exec rm -rf {} + 2>/dev/null || true

  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _disk_parse_path "$dst_dir"
    if [[ -n "$_dp_part" && -n "$_dp_path" ]]; then
      find "$clean_src" -name '_UAEFSDB.___' -delete 2>/dev/null || true
      # Set execute bit on text files so copyFromHost writes them with Amiga 's' bit.
      find "$clean_src" -type f ! -name '*.info' | while IFS= read -r _f; do
        LC_ALL=C grep -q $'\x00' "$_f" 2>/dev/null || chmod +x "$_f" 2>/dev/null || true
      done
      "$AMIGA_TOOLS" disk fs copy "$DISK_ENGINE_IMG" "$_dp_part" "$clean_src" "$_dp_path"
      rm -rf "$clean_src" 2>/dev/null || true
      return 0
    fi
  fi

  if ! "$HST_IMAGER" fs copy "$clean_src" "$dst_dir" --recursive --force --quiet; then
    "$HST_IMAGER" fs copy "$clean_src" "$dst_dir" --recursive --force
  fi

  rm -rf "$clean_src" 2>/dev/null || true
}

is_skippable_overlay_file() {
  local rel="$1"
  local base lower
  base="$(basename "$rel")"
  lower="$(printf "%s" "$base" | tr '[:upper:]' '[:lower:]')"

  case "$base" in
    .DS_Store|._*|*.DS_Store|__MACOSX|.keep|.keepfile|_UAEFSDB.___) return 0 ;;
  esac

  # Assets include helper docs like "*.info.txt" that must NOT end up on SYS:
  # (e.g. Devs/Monitors/Videocore.info.txt makes BindDrivers try to execute text).
  case "$lower" in
    *.info.txt) return 0 ;;
  esac

  return 1
}

fs_copy_tree_filtered() {
  # Recursively copy a host tree into an image path while skipping non-runtime files.
  # Uses a single hst-imager --recursive call (vs one invocation per file) for speed.
  # $1 = source directory on host
  # $2 = destination directory in image
  local src_dir="$1"
  local dst_dir="$2"

  [[ -d "$src_dir" ]] || return 0

  ensure_img_dir "$dst_dir"

  # Strip macOS metadata from a temporary copy of the source so hst-imager's
  # recursive copy doesn't see .DS_Store / ._* / __MACOSX / *.info.txt files.
  # _UAEFSDB.___ files are kept — hst-imager reads them to set Amiga protection bits.
  local clean_src="$STAGE/.fctf_clean_$$.$RANDOM"
  mkdir -p "$clean_src"
  # `cp -a "$src_dir/."` has proven brittle with some LHA-extracted trees
  # (notably AmigaAMP3), even after permissions are normalized. A plain
  # recursive host copy is sufficient here because the image copy step does not
  # depend on preserving macOS metadata.
  cp -R "$src_dir/." "$clean_src"
  # Remove macOS junk and overlay-skippable files in-place
  find "$clean_src" -name '.DS_Store' -delete 2>/dev/null || true
  find "$clean_src" -name '._*' -delete 2>/dev/null || true
  find "$clean_src" -type d -name '__MACOSX' -exec rm -rf {} + 2>/dev/null || true
  find "$clean_src" -name '*.info.txt' -delete 2>/dev/null || true
  # Keep _UAEFSDB.___ — hst-imager reads these during fs copy to set Amiga protection bits
  find "$clean_src" -name '.keep' -delete 2>/dev/null || true
  find "$clean_src" -name '.keepfile' -delete 2>/dev/null || true

  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _disk_parse_path "$dst_dir"
    if [[ -n "$_dp_part" && -n "$_dp_path" ]]; then
      # _UAEFSDB.___ files set Amiga protection bits via hst-imager; not read by amiga-tools — strip them.
      find "$clean_src" -name '_UAEFSDB.___' -delete 2>/dev/null || true
      "$AMIGA_TOOLS" disk fs copy "$DISK_ENGINE_IMG" "$_dp_part" "$clean_src" "$_dp_path"
      rm -rf "$clean_src" 2>/dev/null || true
      return 0
    fi
    # Path not an Amiga RDB partition (e.g. FAT) — fall through to hst-imager
  fi
  # Single recursive copy — dramatically faster than one hst-imager call per file.
  if ! "$HST_IMAGER" fs copy "$clean_src" "$dst_dir" --recursive --force --quiet; then
    # Fallback: retry without --quiet in case that flag causes issues with this version
    "$HST_IMAGER" fs copy "$clean_src" "$dst_dir" --recursive --force
  fi

  rm -rf "$clean_src" 2>/dev/null || true
}

apply_script_bits_for_tree() {
  # Apply Amiga script protection bits to text files copied into the image.
  # $1 = source dir on host (used to read original files)
  # $2 = destination dir in image (where files already exist)
  local src_dir="$1"
  local dst_dir="$2"

  [[ -d "$src_dir" ]] || return 0

  local tmp_dir="$STAGE/scriptbits-$$.$RANDOM"
  rm -rf "$tmp_dir" 2>/dev/null || true
  mkdir -p "$tmp_dir"

  while IFS= read -r -d '' f; do
    local rel base
    rel="${f#$src_dir/}"
    base="$(basename "$rel")"
    case "$base" in
      *.info|*.png) continue ;;
    esac

    # Treat files with NUL bytes as binary; skip those.
    if LC_ALL=C grep -q $'\x00' "$f" 2>/dev/null; then
      continue
    fi

    local tmp_file="$tmp_dir/$rel"
    mkdir -p "$(dirname "$tmp_file")"
    cp "$f" "$tmp_file"
    make_lf_only "$tmp_file"
    write_uaem_with_perms "-s--rwed" "$tmp_file"
    chmod +x "$tmp_file"  # ensures amigadiskkit copyFromHost sets Amiga 's' bit
    fs_copy_file "$tmp_file" "$dst_dir/$rel" --uaemetadata UaeMetafile
  done < <(find "$src_dir" -type f -print0)

  rm -rf "$tmp_dir" 2>/dev/null || true
}

make_lf_only() {
  local f="$1"
  "$AMIGA_TOOLS" normalize-endings "$f"
}

fs_copy() {
  local src="$1"; local dst="$2"; shift 2
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$dst"
    if [[ -n "$_fp_rel" ]]; then
      # Recursive if --recursive flag present, src ends in /, or src is a directory
      local _is_dir=0
      for _a in "$@"; do [[ "$_a" == "--recursive" ]] && _is_dir=1; done
      [[ "$src" == */ || -d "${src%/}" ]] && _is_dir=1
      if [[ $_is_dir -eq 1 ]]; then
        local _src_clean="${src%/}"
        echo "+ $AMIGA_TOOLS disk fat copydir $DISK_ENGINE_IMG $_src_clean $_fp_rel"
        "$AMIGA_TOOLS" disk fat copydir "$DISK_ENGINE_IMG" "$_src_clean" "$_fp_rel"
      else
        echo "+ $AMIGA_TOOLS disk fat copy $DISK_ENGINE_IMG $src $_fp_rel"
        "$AMIGA_TOOLS" disk fat copy "$DISK_ENGINE_IMG" "$src" "$_fp_rel"
      fi
      return
    fi
    _disk_parse_path "$dst"
    if [[ -n "$_dp_part" && -n "$_dp_path" ]]; then
      echo "+ $AMIGA_TOOLS disk fs copy $DISK_ENGINE_IMG $_dp_part $src $_dp_path"
      "$AMIGA_TOOLS" disk fs copy "$DISK_ENGINE_IMG" "$_dp_part" "$src" "$_dp_path"
      return
    fi
  fi
  echo "+ $HST_IMAGER fs copy $src $dst --force $*"
  if "$HST_IMAGER" fs copy "$src" "$dst" --force "$@"; then
    return 0
  fi
  echo "+ $HST_IMAGER fs copy $src $dst $*"
  "$HST_IMAGER" fs copy "$src" "$dst" "$@"
}

ensure_img_dir() {
  # Create a directory inside the image; silently no-op if it already exists.
  # Do NOT verify with fs dir after mkdir: hst-imager fs dir returns non-zero
  # intermittently on complex FFS states (observed: existing dirs fail verification
  # mid-build after many writes). If the directory is genuinely missing, the
  # subsequent fs copy will fail with its own error.
  local dst="$1"
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$dst"
    if [[ -n "$_fp_rel" ]]; then
      "$AMIGA_TOOLS" disk fat mkdir "$DISK_ENGINE_IMG" "$_fp_rel" >/dev/null 2>&1 || true
      return 0
    fi
    _disk_parse_path "$dst"
    if [[ -n "$_dp_part" && -n "$_dp_path" ]]; then
      "$AMIGA_TOOLS" disk fs mkdir "$DISK_ENGINE_IMG" "$_dp_part" "$_dp_path" >/dev/null 2>&1 || true
      return 0
    fi
  fi
  "$HST_IMAGER" fs mkdir "$dst" >/dev/null 2>&1 || true
}

fs_copy_file() {
  # Copy a single file to an exact destination path inside the image.
  # Never pass --makedir here, otherwise hst-imager can turn the destination into a drawer.
  # Extra args after src/dst are passed through to hst-imager (e.g. --uaemetadata UaeMetafile).
  local src="$1"
  local dst="$2"
  shift 2

  local parent
  parent="$(dirname "$dst")"
  ensure_img_dir "$parent"

  # Retry once after re-creating parent drawer to handle hst-imager path flakiness.
  if fs_copy "$src" "$dst" "$@"; then
    return 0
  fi
  echo "==> WARN: fs copy failed for $dst; re-creating parent and retrying once" >&2
  ensure_img_dir "$parent"
  fs_copy "$src" "$dst" "$@"
}

# Pull a single file from image -> host (works even when `fs extract` is unsupported)
fs_pull_file() {
  local imgpath="$1"
  local localfile="$2"
  mkdir -p "$(dirname "$localfile")"
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$imgpath"
    if [[ -n "$_fp_rel" ]]; then
      "$AMIGA_TOOLS" disk fat extract "$DISK_ENGINE_IMG" "$_fp_rel" "$localfile" >/dev/null 2>&1
      return $?
    fi
    _disk_parse_path "$imgpath"
    if [[ -n "$_dp_part" && -n "$_dp_path" ]]; then
      "$AMIGA_TOOLS" disk fs extract "$DISK_ENGINE_IMG" "$_dp_part" "$_dp_path" "$localfile" >/dev/null 2>&1
      return
    fi
  fi
  "$HST_IMAGER" fs copy "$imgpath" "$localfile" --force >/dev/null 2>&1 && return 0
  "$HST_IMAGER" fs copy "$imgpath" "$localfile"         >/dev/null 2>&1 && return 0
  return 1
}

# Check if a path exists in the image (file or directory). Returns 0 if found, non-zero otherwise.
# For simple existence checks: replaces fs_dir "$path"
fs_dir() {
  local vpath="$1"
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$vpath"
    if [[ -n "$_fp_rel" ]]; then
      "$AMIGA_TOOLS" disk fat exists "$DISK_ENGINE_IMG" "$_fp_rel" >/dev/null 2>&1
      return $?
    fi
    _disk_parse_path "$vpath"
    if [[ -n "$_dp_part" ]]; then
      "$AMIGA_TOOLS" disk fs dir "$DISK_ENGINE_IMG" "$_dp_part" "${_dp_path:-}" >/dev/null 2>&1
      return $?
    fi
  fi
  "$HST_IMAGER" fs dir "$vpath" >/dev/null 2>&1
}

# Delete a file or directory from a partition image. Silently succeeds if not found.
# Non-recursive: deleting a non-empty directory leaves contents orphaned.
fs_delete() {
  local vpath="$1"
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$vpath"
    if [[ -n "$_fp_rel" ]]; then
      "$AMIGA_TOOLS" disk fat delete "$DISK_ENGINE_IMG" "$_fp_rel" >/dev/null 2>&1 || true
      return 0
    fi
    _disk_parse_path "$vpath"
    if [[ -n "$_dp_part" ]]; then
      "$AMIGA_TOOLS" disk fs delete "$DISK_ENGINE_IMG" "$_dp_part" "$_dp_path" >/dev/null 2>&1 || true
      return 0
    fi
  fi
  "$HST_IMAGER" fs delete "$vpath" --force >/dev/null 2>&1 || true
}

# List entries under a path recursively; outputs one relative path per line.
# For native engine: uses amiga-tools --recursive (plain paths, one per line).
# For hst-imager: outputs pipe-delimited rows; caller must parse accordingly.
fs_list_recursive() {
  local vpath="$1"
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$vpath"
    if [[ -n "$_fp_rel" ]]; then
      "$AMIGA_TOOLS" disk fat dir "$DISK_ENGINE_IMG" "$_fp_rel" --recursive 2>/dev/null || true
      return
    fi
    _disk_parse_path "$vpath"
    if [[ -n "$_dp_part" ]]; then
      "$AMIGA_TOOLS" disk fs dir "$DISK_ENGINE_IMG" "$_dp_part" "${_dp_path:-}" --recursive 2>/dev/null || true
      return
    fi
  fi
  "$HST_IMAGER" fs dir "$vpath" --recursive 2>/dev/null || true
}

# Dispatch-aware diagnostic listing for sanity-check sections (logs command + output).
# Equivalent to: run "$HST_IMAGER" fs dir "$path" [--recursive]
# Always uses || true internally so it never fails the build.
diag_fs_dir() {
  local vpath="$1"; shift
  local _recursive=0
  [[ "${1:-}" == "--recursive" ]] && _recursive=1
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _fat_parse_path "$vpath"
    if [[ -n "$_fp_rel" ]]; then
      local _cmd="$AMIGA_TOOLS disk fat dir $DISK_ENGINE_IMG $_fp_rel"
      [[ $_recursive -eq 1 ]] && _cmd="$_cmd --recursive"
      echo "+ $_cmd"
      "$AMIGA_TOOLS" disk fat dir "$DISK_ENGINE_IMG" "$_fp_rel" 2>/dev/null || true
      return
    fi
    _disk_parse_path "$vpath"
    if [[ -n "$_dp_part" ]]; then
      local _cmd="$AMIGA_TOOLS disk fs dir $DISK_ENGINE_IMG $_dp_part ${_dp_path:-}"
      [[ $_recursive -eq 1 ]] && _cmd="$_cmd --recursive"
      echo "+ $_cmd"
      if [[ $_recursive -eq 1 ]]; then
        "$AMIGA_TOOLS" disk fs dir "$DISK_ENGINE_IMG" "$_dp_part" "${_dp_path:-}" --recursive 2>/dev/null || true
      else
        "$AMIGA_TOOLS" disk fs dir "$DISK_ENGINE_IMG" "$_dp_part" "${_dp_path:-}" 2>/dev/null || true
      fi
      return
    fi
  fi
  if [[ $_recursive -eq 1 ]]; then
    run "$HST_IMAGER" fs dir "$vpath" --recursive || true
  else
    run "$HST_IMAGER" fs dir "$vpath" || true
  fi
}

# ===========================================================================
# ADF / LHA extraction
# ===========================================================================
extract_adf_to_host() {
  local adf="$1"
  local host_dst="$2"

  echo "  -> extracting ADF to host: $(basename "$adf")"
  mkdir -p "$host_dst"

  # Native engine: amiga-tools adf extract handles both FFS and OFS ADFs without hst-imager.
  # Falls through to hst-imager for unsupported DOS types or unexpected errors.
  if "$AMIGA_TOOLS" adf extract "$adf" "$host_dst" 2>/dev/null; then
    chmod -R u+rwX "$host_dst" 2>/dev/null || true
    return 0
  fi

  "$HST_IMAGER" fs extract "$adf" "$host_dst" --recursive --force && return 0
  "$HST_IMAGER" fs extract "$adf" "$host_dst" --recursive        && return 0
  "$HST_IMAGER" fs copy    "$adf" "$host_dst" --recursive --force && return 0
  "$HST_IMAGER" fs copy    "$adf" "$host_dst" --recursive        && return 0

  die "Failed to extract/copy ADF into host dir: $adf"
}

extract_lha_to_host() {
  local lha_file="$1"
  local host_dst="$2"
  echo "  -> extracting LHA to host: $(basename "$lha_file")"
  mkdir -p "$host_dst"

  # Primary: system lha/lhasa binary (fast C decoder) — same preference order
  # as the CSV install loop and install_wbdock. The Swift decoder in Debug
  # app builds runs -Onone (Xcode hardcodes it for SPM packages) and takes
  # ~115s for the 58 MB ScummVM archive vs ~3s here.
  # lhasa sometimes returns non-zero even on success (unknown Amiga attributes on macOS).
  # Check directory content rather than relying on lha's exit code.
  local _lha=""
  _lha="$(find_lha)" || true
  if [[ -n "$_lha" ]]; then
    (cd "$host_dst" && "$_lha" x "$lha_file") 2>/dev/null || true
    if [[ -n "$(find "$host_dst" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
      chmod -R u+rwX "$host_dst" 2>/dev/null || true
      return 0
    fi
  fi

  # Fallback 1: amiga-tools native Swift LHA decoder (lh5/6/7, level-0/1/2 headers).
  if "$AMIGA_TOOLS" lha extract "$lha_file" "$host_dst" 2>/dev/null; then
    if [[ -n "$(find "$host_dst" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
      chmod -R u+rwX "$host_dst" 2>/dev/null || true
      return 0
    fi
  fi

  # Fallback 2: hst-imager (has IndexOutOfRangeException on some archives on Intel macOS).
  if "$HST_IMAGER" fs extract "$lha_file" "$host_dst" --recursive --force 2>/dev/null \
    || "$HST_IMAGER" fs extract "$lha_file" "$host_dst" --recursive 2>/dev/null
  then
    chmod -R u+rwX "$host_dst" 2>/dev/null || true
    return 0
  fi
  echo "==> WARN: Failed to extract LHA: $(basename "$lha_file")"
  return 1
}

# ---------------------------------------------------------------------------
# parallel_extract_adfs_to_host <host_dst> <adf1> [adf2 ...]
# Extracts multiple ADFs in parallel (up to MAX_PARALLEL_ADF concurrent jobs).
# Falls through to sequential extract_adf_to_host for 0-1 ADFs.
# Dies on failure (after waiting for all in-flight jobs to finish).
# ---------------------------------------------------------------------------
parallel_extract_adfs_to_host() {
  local host_dst="$1"
  shift

  local count=$#
  if [[ $count -eq 0 ]]; then return 0; fi
  if [[ $count -eq 1 ]]; then
    extract_adf_to_host "$1" "$host_dst"
    return 0
  fi

  echo "==> Parallel ADF extraction: $count ADFs, max $MAX_PARALLEL_ADF concurrent"

  local pids=()
  local names=()

  for a in "$@"; do
    (
      set -e
      extract_adf_to_host "$a" "$host_dst"
    ) &
    pids+=($!)
    names+=("$(basename "$a")")

    # Throttle: if at capacity, wait for the oldest job
    if [[ ${#pids[@]} -ge $MAX_PARALLEL_ADF ]]; then
      if ! wait "${pids[0]}"; then
        echo "ERROR: ADF extraction failed: ${names[0]}" >&2
        # Wait for remaining before dying
        for p in "${pids[@]:1}"; do
          wait "$p" 2>/dev/null || true
        done
        die "Parallel ADF extraction failed for: ${names[0]}"
      fi
      pids=("${pids[@]:1}")
      names=("${names[@]:1}")
    fi
  done

  # Drain remaining jobs
  local failed_list=""
  local idx=0
  for pid in "${pids[@]}"; do
    if ! wait "$pid"; then
      failed_list="$failed_list ${names[$idx]}"
    fi
    idx=$((idx + 1))
  done

  if [[ -n "$failed_list" ]]; then
    die "Parallel ADF extraction failed for:$failed_list"
  fi
}

# ===========================================================================
# Host-side ADF merge & collision fixes
# ===========================================================================
fix_sys_host_file_collisions_from_workbench_adf() {
  # Fix A (HOST-SIDE): ensure certain Workbench core paths are FILES (not drawers)
  # BEFORE we copy the merged SYS payload into the image.
  #
  # Root cause we are fixing:
  # - During the ADF merge, some paths that must be files (Prefs/Font, System/CLI, etc.)
  #   can end up as directories. Since hst-imager v1.4.526 has no fs delete/protect,
  #   fixing this inside the image is unreliable.
  #
  # Approach:
  # - Extract the Workbench ADF to a temp host folder, detect its real root (some
  #   extract modes nest under a volume-name folder), and then overwrite the merged
  #   host payload by replacing those drawers with the correct files.

  local workbench_adf="$1"   # e.g. .../Workbench3.2.adf
  local sys_host="$2"        # host merge dir, e.g. $SYS_HOST

  [[ -f "$workbench_adf" ]] || { echo "==> Fix A: Workbench ADF not found: $workbench_adf (skipping)"; return 0; }
  [[ -d "$sys_host" ]]      || { echo "==> Fix A: SYS host dir missing: $sys_host (skipping)"; return 0; }

  local wb_host="$STAGE/wb-adf-extract"
  rm -rf "$wb_host" >/dev/null 2>&1 || true
  mkdir -p "$wb_host"

  echo "==> Fix A: host-side restore of core Workbench files from $(basename "$workbench_adf")"
  extract_adf_to_host "$workbench_adf" "$wb_host"

  # Detect real root of extracted Workbench ADF.
  # Some extract modes create a single top-level folder (volume label).
  local wb_root="$wb_host"
  if [[ ! -d "$wb_root/System" && ! -d "$wb_root/Prefs" ]]; then
    local topdir_count
    topdir_count="$(find "$wb_host" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')"
    local topfile_count
    topfile_count="$(find "$wb_host" -mindepth 1 -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')"
    if [[ "$topdir_count" -eq 1 && "$topfile_count" -eq 0 ]]; then
      wb_root="$(find "$wb_host" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
    fi
  fi

  # Paths that MUST be files on SYS: (not drawers)
  local rel
  for rel in \
    "Prefs/Font" "Prefs/Font.info" \
    "System/CLI" "System/CLI.info" \
    "System/Shell" "System/Shell.info" \
    "System/Format" "System/Format.info" \
    "System/Workbench" "System/Workbench.info" \
    ; do

    # If the merged payload has this path as a directory, remove it so a file can exist
    if [[ -d "$sys_host/$rel" ]]; then
      rm -rf "$sys_host/$rel" >/dev/null 2>&1 || true
    fi

    # Copy the file from Workbench extraction if present
    if [[ -f "$wb_root/$rel" ]]; then
      mkdir -p "$(dirname "$sys_host/$rel")"
      cp -f "$wb_root/$rel" "$sys_host/$rel"
    fi
  done

  # Also restore the compressed variants if they exist (some media use .Z)
  for rel in \
    "Prefs/Font.Z" \
    "System/CLI.Z" \
    "System/Shell.Z" \
    "System/Format.Z" \
    "System/Workbench.Z" \
    ; do

    if [[ -d "$sys_host/$rel" ]]; then
      rm -rf "$sys_host/$rel" >/dev/null 2>&1 || true
    fi

    if [[ -f "$wb_root/$rel" ]]; then
      mkdir -p "$(dirname "$sys_host/$rel")"
      cp -f "$wb_root/$rel" "$sys_host/$rel"
    fi
  done

  # Final host-side sanity (this is BEFORE copying into the image)
  for rel in "Prefs/Font" "System/CLI"; do
    if [[ -d "$sys_host/$rel" ]]; then
      echo "==> Fix A: WARN: host payload still has drawer at $rel (expected file)" >&2
    fi
  done
}
fix_sys_host_file_collisions_from_iso() {
  # Fix A.1 (HOST-SIDE): Use ISO root drawers as authoritative source for a few
  # core Workbench binaries that are NOT reliably present when building SYS: only
  # from ADFs (or may collide into drawers during merges).
  #
  # This prevents:
  # - SYS:Prefs/Font becoming a directory
  # - SYS:System/CLI becoming a directory
  # - missing core tools that later trigger requesters

  local iso_root="$1"   # mounted ISO root
  local sys_host="$2"   # host merge dir

  [[ -d "$iso_root" ]] || return 0
  [[ -d "$sys_host" ]] || return 0

  echo "==> Fix A.1: host-side restore of core files from ISO root"

  # helper: copy a file from one of several candidate ISO locations into sys_host/<rel>
  _restore_file() {
    local rel="$1"; shift
    local src=""

    # If target is currently a directory, remove it so a file can exist.
    if [[ -d "$sys_host/$rel" ]]; then
      rm -rf "$sys_host/$rel" >/dev/null 2>&1 || true
    fi

    # Find first existing source
    for cand in "$@"; do
      if [[ -f "$cand" ]]; then
        src="$cand"
        break
      fi
    done

    if [[ -n "$src" ]]; then
      mkdir -p "$(dirname "$sys_host/$rel")"
      cp -f "$src" "$sys_host/$rel"
    fi
  }

  # Prefs/Font prefs program
  _restore_file "Prefs/Font" \
    "$iso_root/Prefs/Font" \
    "$iso_root/Prefs/Font.Z"
  _restore_file "Prefs/Font.info" \
    "$iso_root/Prefs/Font.info"

  # System CLI/Shell/Format
  # Some media store CLI executable under top-level CLI/CLI.
  _restore_file "System/CLI" \
    "$iso_root/System/CLI" \
    "$iso_root/System/CLI.Z" \
    "$iso_root/CLI/CLI" \
    "$iso_root/CLI/CLI.Z"
  _restore_file "System/CLI.info" \
    "$iso_root/System/CLI.info" \
    "$iso_root/CLI/CLI.info"

  _restore_file "System/Shell" \
    "$iso_root/System/Shell" \
    "$iso_root/System/Shell.Z"
  _restore_file "System/Shell.info" \
    "$iso_root/System/Shell.info"

  _restore_file "System/Format" \
    "$iso_root/System/Format" \
    "$iso_root/System/Format.Z"
  _restore_file "System/Format.info" \
    "$iso_root/System/Format.info"

  # System/Workbench (Workbench executable)
  _restore_file "System/Workbench" \
    "$iso_root/System/Workbench" \
    "$iso_root/System/Workbench.Z" \
    "$iso_root/Workbench" \
    "$iso_root/Workbench.Z"
  _restore_file "System/Workbench.info" \
    "$iso_root/System/Workbench.info" \
    "$iso_root/Workbench.info"

  # Fonts drawer: ensure it exists for FONTS: assign. (Non-destructive: only if missing)
  if [[ ! -d "$sys_host/Fonts" && -d "$iso_root/Fonts" ]]; then
    echo "==> Fix A.1: restoring SYS:Fonts drawer from ISO"
    cp -R "$iso_root/Fonts" "$sys_host/Fonts" >/dev/null 2>&1 || true
  fi
}
canonicalize_sys_host_core_paths() {
  # Host-side canonicalization to prevent file-vs-drawer collisions for core OS binaries.
  # Ensures these are FILES (not drawers): System/CLI, Prefs/Font, System/Shell, System/Format.
  # Also ensures SYS:Fonts exists so FONTS: can be assigned.
  local sys_host="$1"
  local iso_root="$2"

  [[ -d "$sys_host" ]] || return 0

  _rm_dir_if_present() {
    local p="$1"
    [[ -d "$p" ]] && rm -rf "$p" >/dev/null 2>&1 || true
  }

  _copy_if_file() {
    local src="$1" dst="$2"
    [[ -f "$src" ]] || return 1
    mkdir -p "$(dirname "$dst")" >/dev/null 2>&1 || true
    cp -f "$src" "$dst" >/dev/null 2>&1 || true
    return 0
  }

  # --- System/CLI ---
  _rm_dir_if_present "$sys_host/System/CLI"
  if [[ ! -f "$sys_host/System/CLI" ]]; then
    # Prefer ISO root (CLI/CLI is common on 3.2 media)
    _copy_if_file "$iso_root/CLI/CLI" "$sys_host/System/CLI" || true
    _copy_if_file "$iso_root/System/CLI" "$sys_host/System/CLI" || true
    # Fall back to any existing compressed or alternate location in merged payload
    _copy_if_file "$sys_host/System/CLI.Z" "$sys_host/System/CLI" || true
    _copy_if_file "$sys_host/CLI/CLI" "$sys_host/System/CLI" || true
    _copy_if_file "$sys_host/CLI/CLI.Z" "$sys_host/System/CLI" || true
  fi

  # .info for CLI (optional, but nice)
  _rm_dir_if_present "$sys_host/System/CLI.info"
  if [[ ! -f "$sys_host/System/CLI.info" ]]; then
    _copy_if_file "$iso_root/CLI/CLI.info" "$sys_host/System/CLI.info" || true
    _copy_if_file "$iso_root/System/CLI.info" "$sys_host/System/CLI.info" || true
    _copy_if_file "$sys_host/CLI/CLI.info" "$sys_host/System/CLI.info" || true
  fi

  # --- Prefs/Font ---
  _rm_dir_if_present "$sys_host/Prefs/Font"
  if [[ ! -f "$sys_host/Prefs/Font" ]]; then
    # Prefer ISO root if present
    _copy_if_file "$iso_root/Prefs/Font" "$sys_host/Prefs/Font" || true
    # Otherwise, duplicate compressed variant if it exists
    _copy_if_file "$sys_host/Prefs/Font.Z" "$sys_host/Prefs/Font" || true
  fi

  # .info for Font
  _rm_dir_if_present "$sys_host/Prefs/Font.info"
  if [[ ! -f "$sys_host/Prefs/Font.info" ]]; then
    _copy_if_file "$iso_root/Prefs/Font.info" "$sys_host/Prefs/Font.info" || true
  fi

  # --- System/Shell + System/Format (best-effort) ---
  for rel in "System/Shell" "System/Format"; do
    _rm_dir_if_present "$sys_host/$rel"
    if [[ ! -f "$sys_host/$rel" ]]; then
      _copy_if_file "$iso_root/$rel" "$sys_host/$rel" || true
      _copy_if_file "$iso_root/${rel}.Z" "$sys_host/$rel" || true
      _copy_if_file "$sys_host/${rel}.Z" "$sys_host/$rel" || true
    fi
    _rm_dir_if_present "$sys_host/${rel}.info"
    if [[ ! -f "$sys_host/${rel}.info" ]]; then
      _copy_if_file "$iso_root/${rel}.info" "$sys_host/${rel}.info" || true
    fi
  done

  # --- System/Workbench (Workbench executable) ---
  _rm_dir_if_present "$sys_host/System/Workbench"
  if [[ ! -f "$sys_host/System/Workbench" ]]; then
    _copy_if_file "$iso_root/System/Workbench" "$sys_host/System/Workbench" || true
    _copy_if_file "$iso_root/System/Workbench.Z" "$sys_host/System/Workbench" || true
    _copy_if_file "$iso_root/Workbench" "$sys_host/System/Workbench" || true
    _copy_if_file "$iso_root/Workbench.Z" "$sys_host/System/Workbench" || true

    # As a last resort, reuse any Workbench binary already merged (exclude Prefs/Workbench).
    local wb_cand=""
    wb_cand="$(find "$sys_host" -type f -name 'Workbench' \
      ! -path "$sys_host/Prefs/*" \
      ! -path "$sys_host/Install/*" \
      2>/dev/null | head -n 1 || true)"
    if [[ -n "$wb_cand" && "$wb_cand" != "$sys_host/System/Workbench" ]]; then
      cp -f "$wb_cand" "$sys_host/System/Workbench" >/dev/null 2>&1 || true
    fi
  fi

  _rm_dir_if_present "$sys_host/System/Workbench.info"
  if [[ ! -f "$sys_host/System/Workbench.info" ]]; then
    _copy_if_file "$iso_root/System/Workbench.info" "$sys_host/System/Workbench.info" || true
    _copy_if_file "$iso_root/Workbench.info" "$sys_host/System/Workbench.info" || true
  fi

  # --- Ensure SYS:Fonts exists (needed for FONTS: assign) ---
  if [[ ! -d "$sys_host/Fonts" && -d "$iso_root/Fonts" ]]; then
    cp -R "$iso_root/Fonts" "$sys_host/Fonts" >/dev/null 2>&1 || true
  fi

  # --- L/fat95 must be a filesystem binary (not a drawer) ---
  _rm_dir_if_present "$sys_host/L/fat95"
  if [[ ! -f "$sys_host/L/fat95" ]]; then
    _copy_if_file "$ASSETS_DIR/AmigaFiles/L/fat95" "$sys_host/L/fat95" || true
    _copy_if_file "$ASSETS_DIR/AmigaFiles/L/FAT95" "$sys_host/L/fat95" || true
    _copy_if_file "$iso_root/L/fat95" "$sys_host/L/fat95" || true
    _copy_if_file "$iso_root/L/FAT95" "$sys_host/L/fat95" || true
  fi

  # Final host-side sanity
  for rel in "System/CLI" "Prefs/Font"; do
    if [[ -d "$sys_host/$rel" ]]; then
      echo "==> WARN: host SYS payload has drawer at $rel (expected file)" >&2
    fi
  done
}

ensure_user_startup_executes_amigaimager_assigns() {
  # Ensure core assigns exist (especially FONTS: -> SYS:Fonts) without clobbering
  # the base Startup-Sequence.
  local sys_root="$1"
  local boot_name="${PART_NAMES[0]:-}"
  local work_name="${PART_NAMES[1]:-}"
  local boot_upper=""
  local work_upper=""
  boot_upper="$(printf '%s' "$boot_name" | tr '[:lower:]' '[:upper:]')"
  work_upper="$(printf '%s' "$work_name" | tr '[:lower:]' '[:upper:]')"

  local tmp_assigns="$STAGE/amigaimager-assigns"
  {
    echo "; Amiga Imager assigns (added by macOS build)"
    echo "; Avoid requesters and make Font prefs work on fresh installs."
    echo "Assign >NIL: FONTS: SYS:Fonts"
    echo "Assign >NIL: LOCALE: SYS:Locale"
    echo "Assign >NIL: HELP: SYS:Locale/Help"
    echo "IF EXISTS SYS:System/Installer"
    echo "  Assign >NIL: INSTALLER: SYS:System"
    echo "ENDIF"
    # EMU68BOOT (PiStorm only): ensure the FAT32 boot volume mounts even after
    # a HARDRESET (c:EMU68INFO HARDRESET). On cold boot the mass Mount at SS
    # line 222 succeeds and this block is a no-op. After HARDRESET, the Pi's
    # eMMC controller is reset and brcm-emmc.device may not be ready for I/O
    # when the mass Mount runs. In that case the DLT_VOLUME "EMU68BOOT" is never
    # added to the DosList and WB has nothing to display.
    #
    # Recovery: try mounting, then check whether the FAT volume actually came up
    # by testing for config.txt (always present on every PiStorm FAT partition).
    # 'IF NOT EXISTS EMU68BOOT:config.txt' is FALSE via the DosList HEAD —
    #   • If DLT_VOLUME "EMU68BOOT" is present (mass mount or first attempt
    #     succeeded), Lock goes to fat95 → finds config.txt → EXISTS=TRUE → skip.
    #   • If only DLT_DIRECTORY "EMU68BOOT" → SYS: ADD is present (mount
    #     failed), Lock goes to SYS:config.txt → not found → EXISTS=FALSE →
    #     WAIT 3, retry. The 3-second window lets brcm-emmc.device finish
    #     initialising and adds the DLT_VOLUME before WB starts.
    #
    # DEFER avoids an immediate Lock() attempt: the lock is deferred until
    # something first accesses EMU68BOOT:, by which time the device is ready.
    if [[ -n "${FAT_ROOT:-}" ]]; then
      echo "IF EXISTS DEVS:DOSDrivers/SD0"
      echo "  Mount >NIL: DEVS:DOSDrivers/SD0"
      echo "  IF NOT EXISTS EMU68BOOT:config.txt"
      echo "    WAIT 3"
      echo "    Mount >NIL: DEVS:DOSDrivers/SD0"
      echo "  ENDIF"
      echo "  Assign >NIL: EMU68BOOT: SD0: DEFER"
      echo "ENDIF"
    fi
    # staged: alias for SYS: (used by some Aminet install scripts).
    # Wrap in EXISTS check — 'staged:' can't be tested before it's assigned,
    # so use Assign with FAILAT guard instead.
    echo "FAILAT 21"
    echo "Assign >NIL: staged: SYS:"
    echo "FAILAT 10"
    # Keep the actual partition device names, but add compatibility aliases for
    # legacy scripts that still refer to DH0/SDH0/DH1/SDH1.
    echo "FAILAT 21"
    if [[ "$boot_upper" != "DH0" ]]; then
      echo "Assign >NIL: DH0: SYS:"
    fi
    if [[ "$boot_upper" != "SDH0" ]]; then
      echo "Assign >NIL: SDH0: SYS:"
    fi
    echo "IF EXISTS Work:"
    if [[ -n "$work_upper" && "$work_upper" != "DH1" ]]; then
      echo "  Assign >NIL: DH1: Work:"
    fi
    if [[ -n "$work_upper" && "$work_upper" != "SDH1" ]]; then
      echo "  Assign >NIL: SDH1: Work:"
    fi
    echo "ENDIF"
    echo "FAILAT 10"
  } >"$tmp_assigns"
  make_lf_only "$tmp_assigns"
  chmod +x "$tmp_assigns"  # Amiga 's' bit: AmigaDOS assigns script

  ensure_img_dir "$sys_root/S"
  fs_copy_file "$tmp_assigns" "$sys_root/S/amigaimager-assigns"

  # Patch (or create) S:user-startup to execute our assigns script.
  local tmp_us="$STAGE/user-startup"
  if fs_pull_file "$sys_root/S/user-startup" "$tmp_us"; then
    :
  elif fs_pull_file "$sys_root/S/User-Startup" "$tmp_us"; then
    :
  else
    printf "; user-startup created by Amiga Imager\n" > "$tmp_us"
  fi

  if ! grep -qi 'amigaimager-assigns' "$tmp_us"; then
    printf "\n; Amiga Imager assigns\nExecute S:amigaimager-assigns\n" >> "$tmp_us"
  fi

  make_lf_only "$tmp_us"
  chmod +x "$tmp_us"  # Amiga 's' bit: AmigaDOS user-startup script
  fs_copy_file "$tmp_us" "$sys_root/S/user-startup"

  # Also patch S:Startup-Sequence (more reliable than relying on user-startup in every environment).
  local tmp_ss="$STAGE/Startup-Sequence"
  if fs_pull_file "$sys_root/S/Startup-Sequence" "$tmp_ss"; then
    local ss_changed=0
    # Avoid DF0: requesters on systems without a floppy drive.
    if ! grep -qi 'Amiga Imager: DF0 AddBuffers toggle' "$tmp_ss"; then
      if grep -Eqi '^[[:space:]]*AddBuffers[[:space:]].*DF0:' "$tmp_ss"; then
        "$AMIGA_TOOLS" regex-replace --case-insensitive "$tmp_ss" \
          '^(\s*)AddBuffers(.*DF0:.*)$' \
          $'{1}; Amiga Imager: DF0 AddBuffers toggle\n{1}IF "$HAS_FLOPPY" EQ "TRUE"\n{1}  AddBuffers{2}\n{1}ENDIF'
        ss_changed=1
      fi
    fi

    if ! grep -qi 'amigaimager-assigns' "$tmp_ss"; then
      # Inject after mass-Mount / LoadWB so SD0 and RDB partitions are fully online.
      # PiStorm: brcm-emmc.device loads during BindDrivers; SD0 is mass-Mounted after
      # that. Injecting before BindDrivers caused EMU68BOOT to be assigned before the
      # device was ready, leaving a broken DeviceNode and missing assign on first boot.
      if grep -qi '^EndCLI' "$tmp_ss"; then
        "$AMIGA_TOOLS" insert-before-line "$tmp_ss" '^EndCLI' $'; Amiga Imager assigns\nExecute S:amigaimager-assigns\n'
      else
        printf "\n; Amiga Imager assigns\nExecute S:amigaimager-assigns\n" >> "$tmp_ss"
      fi
      ss_changed=1
    fi

    # Ensure User-Startup is executed (some Startup-Sequence variants like KickDisk
    # omit it entirely, which prevents MUI/AmiSSL/Roadshow assigns from running).
    if ! grep -Eqi '^[[:space:]]*(Execute[[:space:]]+)?S:User-Startup' "$tmp_ss"; then
      if grep -qi '^LoadWB' "$tmp_ss"; then
        "$AMIGA_TOOLS" insert-before-line "$tmp_ss" '^LoadWB' $'Execute S:User-Startup\n'
        echo "==> Patched Startup-Sequence: added Execute S:User-Startup before LoadWB"
      else
        # No LoadWB found — insert before EndCLI or append
        if grep -qi '^EndCLI' "$tmp_ss"; then
          "$AMIGA_TOOLS" insert-before-line "$tmp_ss" '^EndCLI' $'Execute S:User-Startup\n'
        else
          printf "\nExecute S:User-Startup\n" >> "$tmp_ss"
        fi
        echo "==> Patched Startup-Sequence: added Execute S:User-Startup"
      fi
      ss_changed=1
    fi

    if [[ "$ss_changed" -eq 1 ]]; then
      make_lf_only "$tmp_ss"
      chmod +x "$tmp_ss"  # Amiga 's' bit: AmigaDOS Startup-Sequence script
      fs_copy_file "$tmp_ss" "$sys_root/S/Startup-Sequence"
    fi
  fi
}

replace_literal_in_host_text_file() {
  local file_path="$1"
  local old_text="$2"
  local new_text="$3"
  local tmp_file="$file_path.tmp"

  awk -v old="$old_text" -v new="$new_text" '{ gsub(old, new); print }' "$file_path" > "$tmp_file" \
    && mv "$tmp_file" "$file_path"
}

patch_partition_name_references_in_file() {
  local sys_root="$1"
  local rel_path="$2"
  local tmp_file="$STAGE/$(basename "$rel_path").partition-names"
  local boot_name="${PART_NAMES[0]:-}"
  local work_name="${PART_NAMES[1]:-}"

  [[ -n "$boot_name" ]] || return 0
  fs_pull_file "$sys_root/$rel_path" "$tmp_file" >/dev/null 2>&1 || return 0

  # Use placeholders to prevent "DH0:" from matching inside "SDH0:" (substring overlap).
  local _ph0="__AMIGAIMG_BOOT_PH0__"
  local _ph1="__AMIGAIMG_WORK_PH1__"
  replace_literal_in_host_text_file "$tmp_file" "SDH0:" "$_ph0"
  replace_literal_in_host_text_file "$tmp_file" "DH0:" "${boot_name}:"
  replace_literal_in_host_text_file "$tmp_file" "$_ph0" "${boot_name}:"
  if [[ -n "$work_name" ]]; then
    replace_literal_in_host_text_file "$tmp_file" "SDH1:" "$_ph1"
    replace_literal_in_host_text_file "$tmp_file" "DH1:" "${work_name}:"
    replace_literal_in_host_text_file "$tmp_file" "$_ph1" "${work_name}:"
  fi

  make_lf_only "$tmp_file"
  chmod +x "$tmp_file"  # Amiga 's' bit: all callers write AmigaDOS script files
  fs_copy_file "$tmp_file" "$sys_root/$rel_path"
}

patch_runtime_partition_references() {
  local sys_root="$1"

  patch_partition_name_references_in_file "$sys_root" "S/Startup-Sequence"
  patch_partition_name_references_in_file "$sys_root" "S/Startup-Sequence_OneTimeRun"
  patch_partition_name_references_in_file "$sys_root" "WBStartup/OnetimeRunWB"
  patch_partition_name_references_in_file "$sys_root" "PiStorm/CheckHashes"
}

# ---------------------------------------------------------------------------
# normalize_locale_drawers <sys_host>
#   Merge nested Locale/ directories + root-level Catalogs/Help/Languages/Countries
#   into $sys_host/Locale/ so AmigaOS Locale Preferences can enumerate them.
# ---------------------------------------------------------------------------
normalize_locale_drawers() {
  local sys_host="$1"
  mkdir -p "$sys_host/Locale" >/dev/null 2>&1 || true

  # 1) Merge any Locale/ directories found at deeper levels (e.g. from nested ADF trees)
  while IFS= read -r -d '' _loc_dir; do
    _parent="$(dirname "$_loc_dir")"
    # Skip the canonical $sys_host/Locale itself
    if [[ "$_parent" == "$sys_host" ]]; then
      continue
    fi
    cp -R "$_loc_dir/"* "$sys_host/Locale/" 2>/dev/null || true
  done < <(find "$sys_host" -mindepth 2 -maxdepth 3 -type d -name Locale -print0 2>/dev/null)

  # 2) Locale ADFs place Catalogs/Help/Languages/Countries at the disk root.
  #    Merge those into Locale/ so Locale prefs can enumerate languages + countries.
  for _d in Catalogs Help Languages Countries; do
    if [[ -d "$sys_host/$_d" ]]; then
      echo "==> Locale merge: $_d -> Locale/$_d"
      mkdir -p "$sys_host/Locale/$_d" >/dev/null 2>&1 || true
      cp -R "$sys_host/$_d/." "$sys_host/Locale/$_d/" 2>/dev/null || true
    fi
  done
}

# ---------------------------------------------------------------------------
# sanitize_sys_host_root <sys_host>
#   Remove installer junk, reorganize fonts/keymaps, fix file collisions.
# ---------------------------------------------------------------------------
sanitize_sys_host_root() {
  local sys_host="$1"
  local _rm_count=0

  # HDToolBox: preserve a usable copy in Tools/
  # IMPORTANT: Only copy .info when none exists in Tools/ — the icon set ADF
  # (GlowIcons/4ColorIcons) places the proper styled icon there. Overwriting it
  # with the HDSetup ADF's generic icon would lose the styled version.
  if [[ -f "$sys_host/HDToolBox" && ! -f "$sys_host/Tools/HDToolBox" ]]; then
    mkdir -p "$sys_host/Tools"
    cp -f "$sys_host/HDToolBox" "$sys_host/Tools/HDToolBox" 2>/dev/null || true
    if [[ -f "$sys_host/HDToolBox.info" && ! -f "$sys_host/Tools/HDToolBox.info" ]]; then
      cp -f "$sys_host/HDToolBox.info" "$sys_host/Tools/HDToolBox.info" 2>/dev/null || true
    fi
  fi
  if [[ -f "$sys_host/HDTools" && ! -f "$sys_host/Tools/HDToolBox" ]]; then
    mkdir -p "$sys_host/Tools"
    cp -f "$sys_host/HDTools" "$sys_host/Tools/HDToolBox" 2>/dev/null || true
    if [[ -f "$sys_host/HDTools.info" && ! -f "$sys_host/Tools/HDToolBox.info" ]]; then
      cp -f "$sys_host/HDTools.info" "$sys_host/Tools/HDToolBox.info" 2>/dev/null || true
    fi
  fi
  # Extract from HDSetup ADF if still missing
  if [[ ! -f "$sys_host/Tools/HDToolBox" ]]; then
    for _hdadf in "$sys_host"/Install/HDSetup*.adf "$sys_host"/Install/HDSetup*.ADF "$sys_host"/Install/hdsetup*.adf "$sys_host"/Install/hdsetup*.ADF; do
      [[ -f "$_hdadf" ]] || continue
      _hdtmp="$STAGE/hdsetup-adf-extract"
      rm -rf "$_hdtmp" >/dev/null 2>&1 || true
      mkdir -p "$_hdtmp"
      echo "==> Extracting HDSetup ADF for HDToolBox fallback: $(basename "$_hdadf")"
      extract_adf_to_host "$_hdadf" "$_hdtmp" || true
      _hd_candidate="$(find "$_hdtmp" -type f \( -iname 'HDToolBox' -o -iname 'HDTools' \) | head -n 1 || true)"
      if [[ -n "$_hd_candidate" ]]; then
        mkdir -p "$sys_host/Tools"
        cp -f "$_hd_candidate" "$sys_host/Tools/HDToolBox" 2>/dev/null || true
        # Only copy .info if none exists (preserve styled icon from GlowIcons/ColorIcons ADF)
        if [[ ! -f "$sys_host/Tools/HDToolBox.info" ]]; then
          _hd_info="$(find "$(dirname "$_hd_candidate")" -maxdepth 1 -type f -iname "$(basename "$_hd_candidate").info" | head -n 1 || true)"
          if [[ -n "$_hd_info" ]]; then
            cp -f "$_hd_info" "$sys_host/Tools/HDToolBox.info" 2>/dev/null || true
          fi
        fi
        break
      fi
    done
  fi
  # Last resort: find anywhere in tree
  if [[ ! -f "$sys_host/Tools/HDToolBox" ]]; then
    _hd_candidate="$(find "$sys_host" -type f \( -iname 'HDToolBox' -o -iname 'HDTools' \) | head -n 1 || true)"
    if [[ -n "$_hd_candidate" ]]; then
      echo "==> Found HDToolBox candidate outside SYS:Tools: $(basename "$_hd_candidate")"
      mkdir -p "$sys_host/Tools"
      cp -f "$_hd_candidate" "$sys_host/Tools/HDToolBox" 2>/dev/null || true
      # Only copy .info if none exists (preserve styled icon from GlowIcons/ColorIcons ADF)
      if [[ ! -f "$sys_host/Tools/HDToolBox.info" ]]; then
        _hd_info="$(find "$(dirname "$_hd_candidate")" -maxdepth 1 -type f -iname "$(basename "$_hd_candidate").info" | head -n 1 || true)"
        if [[ -n "$_hd_info" ]]; then
          cp -f "$_hd_info" "$sys_host/Tools/HDToolBox.info" 2>/dev/null || true
        fi
      fi
    fi
  fi
  # Ensure HDToolBox.info exists
  if [[ -f "$sys_host/Tools/HDToolBox" && ! -f "$sys_host/Tools/HDToolBox.info" ]]; then
    echo "==> HDToolBox binary present but .info missing; searching for donor icon"
    _hd_info="$(find "$sys_host" -type f -iname 'HDToolBox.info' -o -iname 'HDTools.info' 2>/dev/null | head -n 1 || true)"
    if [[ -n "$_hd_info" ]]; then
      cp -f "$_hd_info" "$sys_host/Tools/HDToolBox.info" 2>/dev/null || true
      echo "==> Copied $(basename "$_hd_info") -> Tools/HDToolBox.info"
    else
      for _donor in "$sys_host"/Tools/DiskDoctor.info "$sys_host"/Tools/GraphicDump.info; do
        if [[ -f "$_donor" ]]; then
          cp -f "$_donor" "$sys_host/Tools/HDToolBox.info" 2>/dev/null || true
          echo "==> Using $(basename "$_donor") as fallback icon for HDToolBox.info"
          break
        fi
      done
    fi
  fi

  # Installer tools & junk (Installer binary kept — needed by P96, ZZ9000, etc.)
  for _junk in \
    ActivateCDROM ActivateCDROM.info \
    HDSetup HDSetup.info HDTools HDTools.info \
    Install.info \
    SYStoInstallDisk SYStoInstallDisk.info \
    CI CI.info \
    ; do
    [[ -e "$sys_host/$_junk" ]] && { rm -rf "$sys_host/$_junk"; _rm_count=$((_rm_count+1)); }
  done
  # Move root-level Installer binary to System/Installer (conventional location).
  # WB 3.2 ADFs place the Installer at the root of Install3.2.adf; we relocate
  # it to System/ so INSTALLER: can be assigned to SYS:System as expected.
  if [[ -f "$sys_host/Installer" ]] && [[ ! -f "$sys_host/System/Installer" ]]; then
    mkdir -p "$sys_host/System"
    mv "$sys_host/Installer" "$sys_host/System/Installer"
    [[ -f "$sys_host/Installer.info" ]] && mv "$sys_host/Installer.info" "$sys_host/System/Installer.info"
    echo "==> Relocated Installer → System/Installer"
  fi
  # Background/pattern files
  for _bg in "$sys_host"/background_*.png "$sys_host"/Background_*.png; do
    [[ -f "$_bg" ]] && { rm -f "$_bg"; _rm_count=$((_rm_count+1)); }
  done
  for _iff in default.ntsc.iff default.pal.iff default_ntsc.iff default_pal.iff pattern.iff; do
    [[ -f "$sys_host/$_iff" ]] && { rm -f "$sys_host/$_iff"; _rm_count=$((_rm_count+1)); }
  done
  for _iff_info in default.ntsc.iff.info default.pal.iff.info default_ntsc.iff.info default_pal.iff.info pattern.iff.info; do
    [[ -f "$sys_host/$_iff_info" ]] && { rm -f "$sys_host/$_iff_info"; _rm_count=$((_rm_count+1)); }
  done
  # README / documentation
  for _doc in "$sys_host"/Readme.txt "$sys_host"/Readme.txt.info "$sys_host"/ReadMe.txt "$sys_host"/ReadMe.txt.info; do
    [[ -f "$_doc" ]] && { rm -f "$_doc"; _rm_count=$((_rm_count+1)); }
  done
  # Root-level command/program artifacts
  for _root_tool in CLI CLI.info Shell Shell.info Format Format.info Font Font.info; do
    [[ -f "$sys_host/$_root_tool" ]] && { rm -f "$sys_host/$_root_tool"; _rm_count=$((_rm_count+1)); }
    [[ -d "$sys_host/$_root_tool" ]] && { rm -rf "$sys_host/$_root_tool"; _rm_count=$((_rm_count+1)); }
  done
  # LhA archive self-extraction leftovers
  for _lha_junk in lha_68020 lha_68040 lha_68k lha_68k.readme LhA.guide; do
    [[ -f "$sys_host/$_lha_junk" ]] && { rm -f "$sys_host/$_lha_junk"; _rm_count=$((_rm_count+1)); }
  done
  # Raw .adf files at SYS: root (from ADF extraction — contents already merged)
  for _radf in "$sys_host"/*.adf "$sys_host"/*.ADF; do
    [[ -f "$_radf" ]] && { rm -f "$_radf"; _rm_count=$((_rm_count+1)); }
  done
  for _radfi in "$sys_host"/*.adf.info "$sys_host"/*.ADF.info; do
    [[ -f "$_radfi" ]] && { rm -f "$_radfi"; _rm_count=$((_rm_count+1)); }
  done
  # Install/ directory contains raw ADFs from install media — not needed on final image
  if [[ -d "$sys_host/Install" ]]; then
    rm -rf "$sys_host/Install"
    _rm_count=$((_rm_count+1))
  fi
  [[ -f "$sys_host/Install.info" ]] && { rm -f "$sys_host/Install.info"; _rm_count=$((_rm_count+1)); }
  # Directories from Install/Extras ADFs that aren't standard Workbench drawers
  for _bad_dir in Programs WBSetup "Installer Extras"; do
    [[ -d "$sys_host/$_bad_dir" ]] && { rm -rf "$sys_host/$_bad_dir"; _rm_count=$((_rm_count+1)); }
  done
  # .info files for drawers that don't belong at root
  for _sub_info in DOSDrivers.info Monitors.info Keymaps.info Printers.info DataTypes.info; do
    [[ -f "$sys_host/$_sub_info" ]] && { rm -f "$sys_host/$_sub_info"; _rm_count=$((_rm_count+1)); }
  done
  echo "==> Removed $_rm_count non-Workbench items from SYS_HOST root"

  # --- Reorganize fonts ---
  _font_files=()
  while IFS= read -r _ff; do
    [[ -n "$_ff" ]] && _font_files+=("$_ff")
  done < <(find "$sys_host" -maxdepth 1 -name '*.font' -type f 2>/dev/null)

  if [[ ${#_font_files[@]} -gt 0 ]]; then
    echo "==> Reorganizing fonts: ${#_font_files[@]} .font file(s) found at SYS_HOST root"
    mkdir -p "$sys_host/Fonts"
    for _ff in "${_font_files[@]}"; do
      _fname="$(basename "$_ff")"
      _stem="${_fname%.font}"
      mv "$_ff" "$sys_host/Fonts/$_fname"
      if [[ -d "$sys_host/$_stem" ]]; then
        mv "$sys_host/$_stem" "$sys_host/Fonts/$_stem"
      fi
    done
    for _ot in "$sys_host"/*.otag; do
      [[ -f "$_ot" ]] && mv "$_ot" "$sys_host/Fonts/" 2>/dev/null || true
    done
    for _bd in "$sys_host"/_bullet*; do
      [[ -d "$_bd" ]] && mv "$_bd" "$sys_host/Fonts/" 2>/dev/null || true
    done
    echo "==> Fonts reorganized into SYS_HOST/Fonts/"
  fi

  # Clean corrupt Fonts/ dir
  if [[ -d "$sys_host/Fonts/C" || -d "$sys_host/Fonts/Devs" || -d "$sys_host/Fonts/Libs" ]]; then
    echo "==> WARN: SYS_HOST/Fonts/ contains system directories (corrupt). Cleaning non-font entries."
    for _item in "$sys_host/Fonts"/*; do
      _bname="$(basename "$_item")"
      case "$_bname" in
        *.font|*.otag|_bullet*) continue;;
      esac
      if [[ -f "$sys_host/Fonts/${_bname}.font" ]]; then
        continue
      fi
      rm -rf "$_item" 2>/dev/null || true
    done
  fi

  # --- Reorganize keymaps ---
  # Keymaps may be at the ADF root (AmigaOS 3.1) or inside a Storage/ subdrawer
  # (AmigaOS 3.2 and some install media). Search both locations and merge all
  # found keymaps into Devs/Keymaps/ so Prefs/Input can enumerate them.
  _km_count=0
  mkdir -p "$sys_host/Devs/Keymaps"
  for _km_src_dir in \
      "$sys_host/Keymaps" \
      "$sys_host/Storage/Keymaps" \
      "$sys_host/Extras/Keymaps" \
      "$sys_host/System/Keymaps"; do
    [[ -d "$_km_src_dir" ]] || continue
    for _km in "$_km_src_dir"/*; do
      [[ -e "$_km" ]] || continue
      [[ -f "$_km" ]] || continue   # skip subdirs (none expected, but be safe)
      _km_name="$(basename "$_km")"
      if [[ ! -e "$sys_host/Devs/Keymaps/$_km_name" ]]; then
        cp "$_km" "$sys_host/Devs/Keymaps/$_km_name"
        _km_count=$((_km_count + 1))
      fi
    done
    rm -rf "$_km_src_dir"
  done
  if [[ "$_km_count" -gt 0 ]]; then
    echo "==> Keymaps reorganized: $_km_count keymap(s) merged into Devs/Keymaps/"
  fi

  # Remove IconUpdate drawer
  if [[ -d "$sys_host/IconUpdate" || -f "$sys_host/IconUpdate.info" ]]; then
    echo "==> Removing IconUpdate drawer from SYS payload"
    rm -rf "$sys_host/IconUpdate" >/dev/null 2>&1 || true
    rm -f "$sys_host/IconUpdate.info" >/dev/null 2>&1 || true
  fi
  find "$sys_host" -maxdepth 2 -type d -name "IconUpdate" -exec rm -rf {} + >/dev/null 2>&1 || true
}

# ---------------------------------------------------------------------------
# sanitize_non_ascii_filenames <sys_host>
#   Transliterate non-ASCII filenames to ASCII equivalents for FFS compat.
# ---------------------------------------------------------------------------
sanitize_non_ascii_filenames() {
  local sys_host="$1"
  "$AMIGA_TOOLS" sanitize-filenames "$sys_host" 2>/dev/null \
    || echo "==> WARN: amiga-tools not available for non-ASCII sanitization"
}

extract_host_to_sys() {
  local host_src="$1"
  local sys_dst="$2"
  echo "==> Copying merged SYS payload from host into image"
  if [[ "${DISK_ENGINE:-hst}" == "amigadiskkit" ]]; then
    _disk_parse_path "$sys_dst"
    if [[ -n "$_dp_part" ]]; then
      # _dp_path is empty when sys_dst is the partition root — copyFromHost treats
      # empty amigaPath as the partition root, placing all items there.
      local _clean="$STAGE/.ehs_clean_$$.$RANDOM"
      cp -R "$host_src/." "$_clean"
      find "$_clean" -name '.DS_Store' -delete 2>/dev/null || true
      find "$_clean" -name '._*' -delete 2>/dev/null || true
      find "$_clean" -type d -name '__MACOSX' -exec rm -rf {} + 2>/dev/null || true
      find "$_clean" -name '_UAEFSDB.___' -delete 2>/dev/null || true
      echo "+ $AMIGA_TOOLS disk fs copy $DISK_ENGINE_IMG $_dp_part $_clean ${_dp_path:-/}"
      "$AMIGA_TOOLS" disk fs copy "$DISK_ENGINE_IMG" "$_dp_part" "$_clean" "${_dp_path:-}"
      rm -rf "$_clean" 2>/dev/null || true
      return
    fi
  fi
  # hst-imager path (default, FAT destinations, PFS3 fallback).
  # Use --force directly (no fallback). The retry without --force is broken for
  # recursive copies: the first attempt partially writes files, then the retry
  # immediately fails on the already-written files.
  echo "+ $HST_IMAGER fs copy $host_src/ $sys_dst/ --force --recursive --makedir"
  "$HST_IMAGER" fs copy "$host_src/" "$sys_dst/" --force --recursive --makedir
}

# ===========================================================================
# Overlay injection engine
# ===========================================================================
# -----------------------------------------------------------------------------
# inject_overlay_into_script()
#   Inject the content of an overlay file into an AmigaOS script on the image.
#
#   $1 = sys_root       - hst-imager image path to SYS: root
#   $2 = target_script  - relative path within SYS: (e.g. "S/Startup-Sequence")
#   $3 = overlay_file   - host path to overlay content
#   $4 = mode           - inject_after | inject_before | append
#   $5 = marker         - line prefix to match (case-insensitive) for inject_after/inject_before
#   $6 = overlay_name   - tag used in ; BEGIN / ; END comment markers
# -----------------------------------------------------------------------------
inject_overlay_into_script() {
  local sys_root="$1"
  local target_script="$2"
  local overlay_file="$3"
  local mode="$4"
  local marker="$5"
  local overlay_name="$6"

  if [[ ! -f "$overlay_file" ]]; then
    echo "==> Overlay file not found: $overlay_file (skipping $overlay_name)"
    return 0
  fi

  local target_path="$sys_root/$target_script"
  local tmp_target="$STAGE/inject-target"
  local tmp_result="$STAGE/inject-result"
  local tmp_overlay="$STAGE/inject-overlay"

  # Pull current target from image
  if ! fs_pull_file "$target_path" "$tmp_target"; then
    echo "==> WARN: Cannot pull $target_script from image; skipping overlay $overlay_name"
    return 0
  fi

  # Idempotency: skip if BEGIN marker already present (matches both old and new formats)
  if grep -q "${overlay_name}.*BEGIN\|BEGIN.*${overlay_name}" "$tmp_target" 2>/dev/null; then
    echo "==> Overlay $overlay_name already in $target_script; skipping"
    return 0
  fi

  # Prepare overlay content (LF-only)
  cp "$overlay_file" "$tmp_overlay"
  make_lf_only "$tmp_overlay"

  local begin_mark="; ${overlay_name} - Amiga Imager ${SCRIPT_VERSION} - BEGIN"
  local end_mark="; ${overlay_name} - Amiga Imager ${SCRIPT_VERSION} - END"

  # Guard: FAILAT 30 before overlay content, FAILAT 21 after.
  # This prevents commands inside the overlay (e.g. RemLib) from aborting the
  # Startup-Sequence if they return a non-zero code before the script's own
  # FailAt line has been reached.
  local failat_on="FAILAT 30"
  local failat_off="FAILAT 21"

  case "$mode" in
    inject_after)
      # Insert overlay block AFTER the first line whose lowercase starts with $marker
      awk -v marker="$marker" -v bm="$begin_mark" -v em="$end_mark" \
          -v ofile="$tmp_overlay" -v fon="$failat_on" -v foff="$failat_off" '
      BEGIN { done = 0 }
      {
        print
        if (done == 0) {
          low  = tolower($0)
          mlow = tolower(marker)
          if (index(low, mlow) == 1) {
            print ""
            print bm
            print fon
            while ((getline line < ofile) > 0) print line
            close(ofile)
            print foff
            print em
            done = 1
          }
        }
      }' "$tmp_target" > "$tmp_result"
      ;;
    inject_before)
      # Insert overlay block BEFORE the first line whose lowercase starts with $marker
      awk -v marker="$marker" -v bm="$begin_mark" -v em="$end_mark" \
          -v ofile="$tmp_overlay" -v fon="$failat_on" -v foff="$failat_off" '
      BEGIN { done = 0 }
      {
        if (done == 0) {
          low  = tolower($0)
          mlow = tolower(marker)
          if (index(low, mlow) == 1) {
            print bm
            print fon
            while ((getline line < ofile) > 0) print line
            close(ofile)
            print foff
            print em
            print ""
            done = 1
          }
        }
        print
      }' "$tmp_target" > "$tmp_result"
      ;;
    append)
      cp "$tmp_target" "$tmp_result"
      {
        printf "\n%s\n" "$begin_mark"
        printf "%s\n" "$failat_on"
        cat "$tmp_overlay"
        printf "%s\n" "$failat_off"
        printf "%s\n" "$end_mark"
      } >> "$tmp_result"
      ;;
    remove)
      # Remove an existing overlay block (by overlay_name) from the target script
      awk -v name="$overlay_name" '
      BEGIN { skip = 0 }
      {
        if ($0 ~ name && $0 ~ /BEGIN/) { skip = 1; next }
        if ($0 ~ name && $0 ~ /END/)   { skip = 0; next }
        if (skip == 0) print
      }' "$tmp_target" > "$tmp_result"
      ;;
    *)
      echo "==> WARN: Unknown injection mode '$mode' for overlay $overlay_name"
      return 1
      ;;
  esac

  # Check if marker was found (for inject_after/inject_before)
  if [[ "$mode" != "append" && "$mode" != "remove" ]] && ! grep -qF "$begin_mark" "$tmp_result" 2>/dev/null; then
    echo "==> WARN: Marker '$marker' not found in $target_script; appending $overlay_name as fallback"
    cp "$tmp_target" "$tmp_result"
    {
      printf "\n%s\n" "$begin_mark"
      printf "%s\n" "$failat_on"
      cat "$tmp_overlay"
      printf "%s\n" "$failat_off"
      printf "%s\n" "$end_mark"
    } >> "$tmp_result"
  fi

  make_lf_only "$tmp_result"
  chmod +x "$tmp_result"  # preserve Amiga 's' bit on assembled scripts
  fs_copy_file "$tmp_result" "$target_path"
  echo "==> Injected overlay: $overlay_name -> $target_script ($mode${marker:+ '$marker'})"

  rm -f "$tmp_target" "$tmp_result" "$tmp_overlay" 2>/dev/null || true
}

# Bridge legacy overlay/package names to current Swift package IDs.
package_selection_aliases() {
  local pkg="$1"
  case "$(printf '%s' "$pkg" | tr '[:upper:]' '[:lower:]')" in
    rysmkii|ryswheel)
      printf '%s\n' "RysMKII" "ryswheel"
      ;;
    *)
      printf '%s\n' "$pkg"
      ;;
  esac
}

# Check whether a package name appears in the comma-separated $PACKAGES list.
package_is_selected() {
  local pkg="$1"
  local pkg_aliases=""
  local pkg_alias=""
  pkg_aliases="$(package_selection_aliases "$pkg")"
  # Special case: Roadshow overlay should be injected when network stack is roadshow
  if echo "$pkg" | grep -qi "roadshow" && [[ "${NETWORK_STACK:-}" == "roadshow" ]]; then
    return 0
  fi
  # If user provided paid Picasso96 archive, treat Picasso96 overlay as selected.
  # This keeps startup assigns/parity logic in sync for RTG modes.
  if [[ -n "${P96_ICOMP_ARCHIVE:-}" ]] && echo "$pkg" | grep -qi "picasso96"; then
    return 0
  fi
  # Also check if picasso96.lha is in the Aminet directory (shareware fallback).
  if echo "$pkg" | grep -qi "picasso96" && [[ -n "${AMINET_DIR:-}" && -f "$AMINET_DIR/picasso96.lha" ]]; then
    return 0
  fi
  # If AmiSSL LHA is present in the IBrowse staging directory, treat AmiSSL overlay as selected.
  if [[ -n "${IBROWSE_DIR:-}" && -d "${IBROWSE_DIR:-}" ]] && echo "$pkg" | grep -qi "amissl"; then
    local _ssl_lha
    for _ssl_lha in "${IBROWSE_DIR}"/AmiSSL*.lha; do
      [[ -f "$_ssl_lha" ]] && return 0
    done
  fi
  # WHDLoad is sourced from the external staging dir (whdload.de), not Aminet.
  if [[ -n "${IBROWSE_DIR:-}" && -d "${IBROWSE_DIR:-}" ]] && echo "$pkg" | grep -qi "whdload"; then
    local _whdl_lha
    for _whdl_lha in "${IBROWSE_DIR}"/WHDLoad_usr.lha "${IBROWSE_DIR}"/WHDLoad*.lha; do
      [[ -f "$_whdl_lha" ]] && return 0
    done
  fi
  # Input drivers are mutually exclusive in the UI, but older builds could still
  # end up with both TankMouse and RysWheel selected. Prefer TankMouse/Hid2Ami
  # over RysWheel so only one startup overlay is injected.
  if printf '%s\n' "$pkg_aliases" | grep -Fqi -x "RysMKII"; then
    if echo ",${PACKAGES:-}," | grep -qi ",tankmouse," || echo ",${PACKAGES:-}," | grep -qi ",hid2ami,"; then
      return 1
    fi
  fi
  # Auto-select RysMKII when VA2000 RTG card is selected.
  # RysWheel (from RysMKII package) is installed automatically by install_va2000_for_classic.
  if [[ "${RTG_CARD:-}" == "VA2000" ]]; then
    while IFS= read -r pkg_alias; do
      [[ -n "$pkg_alias" ]] || continue
      if printf '%s\n' "$pkg_alias" | grep -Fqi -x "RysMKII"; then
        return 0
      fi
    done <<< "$pkg_aliases"
  fi

  # Check if Aminet directory contains a .lha whose package hint matches this overlay name.
  if [[ -n "${AMINET_DIR:-}" && -d "${AMINET_DIR:-}" ]]; then
    local _lha
    for _lha in "$AMINET_DIR"/*.lha "$AMINET_DIR"/*.LHA; do
      [[ -f "$_lha" ]] || continue
      local _hint
      _hint="$(aminet_package_hint_for_archive "$(basename "$_lha")" 2>/dev/null || true)"
      [[ -n "$_hint" ]] || continue
      while IFS= read -r pkg_alias; do
        [[ -n "$pkg_alias" ]] || continue
        if printf '%s\n' "$_hint" | grep -Fqi -x "$pkg_alias"; then
          return 0
        fi
      done <<< "$pkg_aliases"
    done
  fi
  [[ -n "$PACKAGES" ]] || return 1
  local packages_lc=""
  packages_lc="$(printf '%s' "$PACKAGES" | tr '[:upper:]' '[:lower:]')"
  while IFS= read -r pkg_alias; do
    local pkg_alias_lc=""
    [[ -n "$pkg_alias" ]] || continue
    pkg_alias_lc="$(printf '%s' "$pkg_alias" | tr '[:upper:]' '[:lower:]')"
    case ",$packages_lc," in
      *,"$pkg_alias_lc",*) return 0 ;;
    esac
  done <<< "$pkg_aliases"
  return 1
}

# -----------------------------------------------------------------------------
# inject_startup_and_user_startup_overlays()
#   Windows-parity: inject overlay script fragments from Assets/AmigaFiles/System/S/
#   into the base Startup-Sequence and User-Startup on the image.
#
#   Startup-Sequence overlays (always injected if present in Assets):
#     Startup-Sequence_Iconlib     -> inject_after  SetPatch
#     Startup-Sequence_OneTimeRun  -> inject_before BindDrivers
#     Startup-Sequence_UAEGFX     -> inject_after  BindDrivers
#     Startup-Sequence_REXXMAST   -> inject_before BindDrivers
#
#   User-Startup overlays (injected only when matching package is selected):
#     User-Startup_Picasso96      -> append (if Picasso96 in PACKAGES)
#     User-Startup_Roadshow       -> append (if Roadshow in PACKAGES)
#     User-Startup_AmiSSL         -> append (if AmiSSL in PACKAGES)
#     User-Startup_MUI38          -> append (if MUI38 in PACKAGES)
# -----------------------------------------------------------------------------
inject_startup_and_user_startup_overlays() {
  local sys_root="$1"
  local overlay_dir="$2"   # e.g. $SYS_PAYLOAD/S

  [[ -d "$overlay_dir" ]] || { echo "==> No overlay dir at $overlay_dir; skipping script injection"; return 0; }

  echo "==> Injecting script overlays from Assets/System/S/"

  # --- Startup-Sequence overlays (always injected) ---
  local ss="S/Startup-Sequence"

  local f="$overlay_dir/Startup-Sequence_Iconlib"
  [[ -f "$f" ]] && inject_overlay_into_script "$sys_root" "$ss" "$f" inject_after "SetPatch" "Iconlib"

  # REXXMAST must be injected BEFORE OneTimeRun so that ARexx is available
  # for scripts like CheckScreenModeandChipset.rexx that need RexxMast.
  # SKIP if the Startup-Sequence already has a RexxMast call (HD Startup-Sequence
  # from Update3.2.3 ADF includes one) — injecting ours causes a duplicate call
  # which fails with returncode 20 ("already running") and can abort the script.
  f="$overlay_dir/Startup-Sequence_REXXMAST"
  if [[ -f "$f" ]]; then
    local _ss_check="$STAGE/.ss-rexx-check.$$"
    if fs_pull_file "$sys_root/$ss" "$_ss_check" 2>/dev/null && \
       grep -qi 'RexxMast' "$_ss_check" 2>/dev/null; then
      echo "==> REXXMAST overlay: skipped (Startup-Sequence already calls RexxMast)"
    else
      inject_overlay_into_script "$sys_root" "$ss" "$f" inject_before "BindDrivers" "REXXMAST"
    fi
    rm -f "$_ss_check" 2>/dev/null || true
  fi

  f="$overlay_dir/Startup-Sequence_OneTimeRun"
  [[ -f "$f" ]] && inject_overlay_into_script "$sys_root" "$ss" "$f" inject_before "BindDrivers" "OneTimeRun"

  f="$overlay_dir/Startup-Sequence_UAEGFX"
  [[ -f "$f" ]] && inject_overlay_into_script "$sys_root" "$ss" "$f" inject_after "BindDrivers" "UAEGFX"

  # --- User-Startup overlays (conditional on selected packages) ---
  local us="S/user-startup"

  # Try both case variants for pulling
  if ! fs_dir "$sys_root/S/user-startup"; then
    if fs_dir "$sys_root/S/User-Startup"; then
      us="S/User-Startup"
    fi
  fi

  for overlay in "$overlay_dir"/User-Startup_*; do
    [[ -f "$overlay" ]] || continue
    local base
    base="$(basename "$overlay")"
    local pkg="${base#User-Startup_}"

    # CardPatch is auto-applied by build-classic.sh for A1200 + PCMCIA only.
    # Not a user-selectable package, so skip it in the generic loop.
    if [[ "$pkg" == "CardPatch" ]]; then
      continue
    fi

    if package_is_selected "$pkg"; then
      inject_overlay_into_script "$sys_root" "$us" "$overlay" append "" "$pkg"
    else
      echo "==> Skipping overlay $base (package $pkg not selected)"
    fi
  done
}

# ===========================================================================
# Partition helpers
# ===========================================================================
# Native-engine partition-number resolution: numbers equal PART-chain order,
# which rdb-build/rdb-reinit write in PART_NAMES[] order. Verify against the
# on-disk RDB via `amiga-tools disk rdb-info` instead of hst-imager.
# Sets PART_PNS[]. Expects globals: IMG, AMIGA_TOOLS, NUM_PARTS, PART_NAMES[].
native_resolve_part_pns() {
  PART_PNS=()
  local _info _i _pn
  _info="$("$AMIGA_TOOLS" disk rdb-info "$IMG" 2>/dev/null || true)"
  for _i in $(seq 0 $((NUM_PARTS - 1))); do
    _pn="$(echo "$_info" | awk -v want="${PART_NAMES[$_i]}" '
      /^Partitions:/{inp=1; next}
      inp && $2 == want {print $1; exit}')"
    [[ -n "$_pn" ]] || die "[amigadiskkit] Failed to resolve partition number for ${PART_NAMES[$_i]} via disk rdb-info"
    PART_PNS+=("$_pn")
  done
}

rdb_partnum_by_name() {
  local info="$1"
  local name="$2"
  echo "$info" | awk -F'|' -v want="$name" '
    BEGIN{inparts=0}
    /^Partitions:/{inparts=1; next}
    inparts {
      gsub(/^[ \t]+|[ \t]+$/, "", $1)
      gsub(/^[ \t]+|[ \t]+$/, "", $2)
      if ($2 == want) { print $1; exit }
    }
    /^Partition table overview:/{exit}
  '
}

# Map partition FS type string to hst-imager DOS type for rdb part add.
# Boot safety: DOS7 (FfsDC) is downgraded to DOS3 for bootable partitions.
part_fs_to_dostype() {
  local fs="$1"
  local is_boot="${2:-0}"
  local dt
  case "$fs" in
    Ffs|FFS)  dt="DOS3" ;;
    FfsIntl)  dt="DOS3" ;;
    FfsDC)    dt="DOS7" ;;
    Pfs3)     dt="PDS3" ;;
    *)        dt="DOS3" ;;
  esac
  # Boot safety: never boot from DOS7 (some Kickstarts hang)
  if [[ "$is_boot" == "1" && "$dt" == "DOS7" ]]; then
    dt="DOS3"
  fi
  echo "$dt"
}

# ---------------------------------------------------------------------------
# upgrade_large_ffs_data_partitions
#
# Amiga FFS (DOS3) with 512-byte blocks on partitions > 2 GiB is unreliable:
# hst-imager's `rdb part format` reports "Done" but the written boot block
# ends up with a zero DOS type, causing "Invalid fast file system dos type
# '00000000' in boot block" whenever something tries to mount the partition
# (e.g. the Transfer folder copy step).
#
# Scan the global PART_* arrays; for each non-bootable partition whose
# fstype is Ffs/FFS AND size is >= 2 GiB, auto-upgrade to FfsDC (DOS7-Long).
# DOS7 supports partitions up to 104 GiB and is fully compatible with the
# AmigaOS 3.2 FastFileSystem we install. Boot partitions stay on DOS3 for
# Kickstart compatibility (same rule as part_fs_to_dostype).
#
# Expects these globals to be set by the caller before invocation:
#   NUM_PARTS            — number of partitions
#   PART_NAMES[]         — partition device names (SDH0, SDH1, ...)
#   PART_LABELS[]        — partition volume labels
#   PART_BYTES[]         — partition sizes in bytes
#   PART_FSTYPES[]       — partition fs tokens (Ffs, FfsDC, Pfs3, ...)
#   PART_BOOTABLE[]      — "1" if bootable, "0" otherwise
#
# Modifies PART_FSTYPES[] in place.
# ---------------------------------------------------------------------------
upgrade_large_ffs_data_partitions() {
  local _limit_bytes=$((2 * 1024 * 1024 * 1024))  # 2 GiB
  local _upgraded=0
  local _i
  for _i in $(seq 0 $((NUM_PARTS - 1))); do
    local _fstype="${PART_FSTYPES[$_i]}"
    local _bytes="${PART_BYTES[$_i]}"
    local _bootable="${PART_BOOTABLE[$_i]}"
    case "$_fstype" in Ffs|FFS|FfsIntl) ;; *) continue ;; esac
    [[ "$_bootable" == "1" ]] && continue
    if (( _bytes >= _limit_bytes )); then
      local _mib=$(( _bytes / 1024 / 1024 ))
      echo "==> Auto-upgrading ${PART_NAMES[$_i]} (${PART_LABELS[$_i]}, ${_mib} MiB) from FFS (DOS3) to FfsDC (DOS7)"
      echo "==>   DOS3 is unreliable above 2 GiB with 512-byte blocks; DOS7 (FFS-Long) handles it cleanly."
      PART_FSTYPES[$_i]="FfsDC"
      _upgraded=$((_upgraded + 1))
    fi
  done
  if (( _upgraded > 0 )); then
    echo "==> Auto-upgraded ${_upgraded} data partition(s) to FfsDC"
    # For the hst-imager path the image already exists here, so register DOS7 now.
    # For the amigadiskkit path the image is created later (rdb-build is deferred);
    # set NEEDS_DOS7_FS_REGISTRATION=1 and let the build script call ensure_rdb_has_dos7_fs
    # after rdb-build creates the file.
    if [[ -f "${IMG:-}" ]]; then
      ensure_rdb_has_dos7_fs
    else
      NEEDS_DOS7_FS_REGISTRATION=1
    fi
  fi
}

# ---------------------------------------------------------------------------
# _native_build_part_args <num_parts>
#
# Build the --part argument array for `amiga-tools disk rdb-build` / `rdb-reinit`.
# Reads the global PART_NAMES[], PART_FSTYPES[], PART_BOOTABLE[], PART_BOOTPRI[],
# PART_BLOCKSIZES[], and PART_BYTES[] arrays (set by partition_layout_resolve_bytes).
# Must be called AFTER upgrade_large_ffs_data_partitions so PART_FSTYPES[] reflects
# any DOS3→DOS7 upgrades.
#
# Sets NATIVE_PART_ARGS[]. The last partition always gets sizeCylinders=0 (fill
# remaining) regardless of PART_BYTES[]; all others are converted to cylinders
# (standard geometry: 16h × 63s × 512b = 516096 bytes/cylinder).
# ---------------------------------------------------------------------------
_native_build_part_args() {
  local _n="${1:-$NUM_PARTS}"
  NATIVE_PART_ARGS=()
  local _BLK_PER_CYL=516096
  local _i _dostype _fsblk _boot _cyls
  for _i in $(seq 0 $((_n - 1))); do
    _dostype="$(part_fs_to_dostype "${PART_FSTYPES[$_i]}" "${PART_BOOTABLE[$_i]}")"
    # Use de_SectorsPerBlock=4 (2048-byte FS blocks) for all FFS types.
    # FFSFormatter reads de_SectorsPerBlock from the PART block, so this controls
    # the on-disk ht_size (456) and root block location — matching hst-imager's
    # convention.  Required for PiStorm/Emu68 hardware: all prior images used
    # 2048-byte FS blocks and hardware boot code expects that geometry.
    case "$_dostype" in
      PDS3) _fsblk=1 ;;  # 512-byte blocks — PFS3 works on raw sectors (hst-imager parity)
      DOS7) _fsblk=4 ;;  # 2048-byte FFS blocks (DOS\7 / FfsDC)
      *)    _fsblk=4 ;;  # 2048-byte FFS blocks (all other FFS — hst-imager parity)
    esac
    _boot="0"; [[ "${PART_BOOTABLE[$_i]}" == "1" ]] && _boot="boot"
    if [[ $_i -lt $((_n - 1)) ]]; then
      _cyls=$(( PART_BYTES[$_i] / _BLK_PER_CYL ))
    else
      _cyls=0   # last partition: fill remaining cylinders
    fi
    NATIVE_PART_ARGS+=(--part "${PART_NAMES[$_i]}:${_dostype}:${_cyls}:${_boot}:${PART_BOOTPRI[$_i]}:${_fsblk}")
  done
}

# ---------------------------------------------------------------------------
# _native_pure_rdb_usable_bytes <disk_size_bytes>
#
# Compute USABLE_BYTES and RDB_BYTES for the native engine on a pure-RDB image
# (Classic / MiSTer — no FAT partition).  This replaces the hst-imager
# `rdb info` read that is otherwise needed to derive USABLE_BYTES.
#
# Standard geometry: 16 heads × 63 sectors × 512 bytes = 516096 bytes/cylinder.
# Reserves: 2 cylinders for RDSK header + 66 cylinders (~32 MiB) for tail safety.
# Sets globals USABLE_BYTES and RDB_BYTES.
# ---------------------------------------------------------------------------
_native_pure_rdb_usable_bytes() {
  local _disk_bytes="$1"
  local _BLK_PER_CYL=516096
  local _total_cyls=$(( _disk_bytes / _BLK_PER_CYL ))
  USABLE_BYTES=$(( (_total_cyls - 68) * _BLK_PER_CYL ))
  RDB_BYTES="$_disk_bytes"
}

# Register DOS7 (FFS-Long, FfsDC) in the RDB file-system table, pointing at
# the FastFileSystem binary already in FFS_PATH. Safe to call multiple times:
# if DOS7 is already registered, the extra `rdb fs add` is a no-op on our
# code path (we only call it when we just upgraded something).
#
# Expects globals: IMG, RDB, DISK_ENGINE, AMIGA_TOOLS, HST_IMAGER, FFS_PATH.
ensure_rdb_has_dos7_fs() {
  if [[ -z "${FFS_PATH:-}" || ! -f "$FFS_PATH" ]]; then
    echo "==> WARN: FFS_PATH not set or missing; cannot register DOS7 filesystem" >&2
    echo "==> WARN: partitions using FfsDC will fail to mount on Amiga" >&2
    return 0
  fi
  echo "==> Registering DOS7 (FFS-Long) filesystem in RDB (required for FfsDC partitions)"
  if [[ "$DISK_ENGINE" == "amigadiskkit" && -n "${AMIGA_TOOLS:-}" ]]; then
    # Native registrar takes the plain image path (auto-detects MBR vs pure RDB),
    # not hst-imager's "$IMG/rdb" virtual path. Idempotent on re-registration.
    run "$AMIGA_TOOLS" disk rdb-fs-add "$IMG" "$FFS_PATH" DOS7 --name FastFileSystem \
      || die "Failed to register DOS7 filesystem in RDB"
  else
    run "$HST_IMAGER" rdb fs add "$RDB" "$FFS_PATH" DOS7 --name FastFileSystem \
      || die "Failed to register DOS7 filesystem in RDB"
  fi
}

# ===========================================================================
# First-time boot flags
# ===========================================================================
ensure_firsttimeboot_flags_true() {
  local sys_root="$1"
  local tmp1="$STAGE/FIRSTTIMEBOOT"
  local tmp2="$STAGE/FIRSTTIMEBOOTWB"
  local tmp3="$STAGE/HAS_FLOPPY"

  printf "TRUE\n" > "$tmp1"
  printf "TRUE\n" > "$tmp2"
  local floppy="FALSE"
  case "$(printf "%s" "${HAS_FLOPPY:-0}" | tr '[:upper:]' '[:lower:]')" in
    1|true|yes|on) floppy="TRUE" ;;
  esac
  printf "%s\n" "$floppy" > "$tmp3"
  make_lf_only "$tmp1"
  make_lf_only "$tmp2"
  make_lf_only "$tmp3"

  local mk="$STAGE/.mkprefs"
  rm -rf "$mk" >/dev/null 2>&1 || true
  mkdir -p "$mk/Prefs/Env-Archive"
  printf "keep\n" > "$mk/Prefs/Env-Archive/.keep"
  make_lf_only "$mk/Prefs/Env-Archive/.keep"

  fs_copy "$mk/Prefs/" "$sys_root/Prefs/" --recursive --makedir >/dev/null 2>&1 || true

  fs_copy_file "$tmp1" "$sys_root/Prefs/Env-Archive/FIRSTTIMEBOOT"
  fs_copy_file "$tmp2" "$sys_root/Prefs/Env-Archive/FIRSTTIMEBOOTWB"
  fs_copy_file "$tmp3" "$sys_root/Prefs/Env-Archive/HAS_FLOPPY"
}

# ===========================================================================
# HDToolBox compatibility
# ===========================================================================
ensure_hdtoolbox_compat() {
  local sys_root="$1"
  echo "==> Verifying HDToolBox availability"

  # NOTE: hst-imager fs dir returns 0 even for non-existent paths, so we must
  # use fs_pull_file (which does an actual copy) as the reliable existence check.
  local have_binary=0
  local have_icon=0
  local icon_has_coloricon=0
  local _probe="$STAGE/.hdtoolbox-probe"
  local _icon_tmp="$STAGE/.hdtoolbox-icon.info"

  rm -f "$_probe" "$_icon_tmp" 2>/dev/null || true
  if fs_pull_file "$sys_root/Tools/HDToolBox" "$_probe" 2>/dev/null; then
    have_binary=1
  fi
  rm -f "$_probe" 2>/dev/null || true
  if fs_pull_file "$sys_root/Tools/HDToolBox.info" "$_icon_tmp" 2>/dev/null; then
    have_icon=1
    # Check if the .info has Color Icon data (FACE chunk = OS 3.5+ icon)
    if grep -qc 'FACE' "$_icon_tmp" 2>/dev/null; then
      icon_has_coloricon=1
    fi
  fi

  echo "==> HDToolBox probe: binary=$have_binary icon=$have_icon coloricon=$icon_has_coloricon"

  # If binary missing, try to clone from HDTools
  if [[ $have_binary -eq 0 ]]; then
    rm -f "$_probe" 2>/dev/null || true
    if fs_pull_file "$sys_root/Tools/HDTools" "$_probe" 2>/dev/null; then
      rm -f "$_probe" 2>/dev/null || true
      echo "==> HDToolBox missing; cloning SYS:Tools/HDTools -> SYS:Tools/HDToolBox"
      local _hdtb_tmp="$STAGE/.hdtools-$$.$RANDOM"
      if fs_pull_file "$sys_root/Tools/HDTools" "$_hdtb_tmp" 2>/dev/null; then
        fs_copy_file "$_hdtb_tmp" "$sys_root/Tools/HDToolBox" 2>/dev/null || true
        rm -f "$_hdtb_tmp" 2>/dev/null || true
      else
        "$HST_IMAGER" fs copy "$sys_root/Tools/HDTools" "$sys_root/Tools/HDToolBox" --force >/dev/null 2>&1 || \
          "$HST_IMAGER" fs copy "$sys_root/Tools/HDTools" "$sys_root/Tools/HDToolBox" >/dev/null 2>&1 || true
        rm -f "$_hdtb_tmp" 2>/dev/null || true
      fi
      have_binary=1
    else
      echo "==> WARN: SYS:Tools/HDToolBox missing and SYS:Tools/HDTools not found"
    fi
  fi

  # Ensure .info icon exists AND has Color Icon data.
  # Old-style planar-only icons (no FACE/IMAG chunks) display as invisible or
  # blank pages in OS 3.2 Workbench with GlowIcon support.  Replace them with
  # a donor tool icon that has proper Color Icon imagery.
  if [[ $have_binary -eq 1 && ( $have_icon -eq 0 || $icon_has_coloricon -eq 0 ) ]]; then
    local _need_donor=1
    local _reason="missing"
    [[ $have_icon -eq 1 && $icon_has_coloricon -eq 0 ]] && _reason="old-style (no Color Icon)"
    echo "==> HDToolBox.info $_reason; looking for donor icon with Color Icon data"

    # Export existing tooltypes before replacing (if icon exists)
    local _tt_file="$STAGE/.hdtoolbox-tt.txt"
    local _have_tt=0
    if [[ $have_icon -eq 1 ]]; then
      if "$AMIGA_TOOLS" icon-export-tooltypes "$_icon_tmp" "$_tt_file" >/dev/null 2>&1; then
        _have_tt=1
      fi
    fi

    # Try donor icons in preference order
    local _donor_found=0
    for _donor in "Tools/DiskDoctor.info" "Tools/GraphicDump.info" "Tools/PrepCard.info" "Tools/ShowConfig.info"; do
      rm -f "$_probe" 2>/dev/null || true
      if fs_pull_file "$sys_root/$_donor" "$_probe" 2>/dev/null; then
        if grep -qc 'FACE' "$_probe" 2>/dev/null; then
          echo "==> Using $_donor as Color Icon donor for HDToolBox.info"
          cp -f "$_probe" "$_icon_tmp"
          # Restore HDToolBox tooltypes into the donor icon
          if [[ $_have_tt -eq 1 ]]; then
            "$AMIGA_TOOLS" icon-import-tooltypes "$_icon_tmp" "$_tt_file" >/dev/null 2>&1 || true
          fi
          # Copy the fixed icon back to the image
          fs_copy_file "$_icon_tmp" "$sys_root/Tools/HDToolBox.info"
          have_icon=1
          _donor_found=1
          break
        fi
      fi
    done

    if [[ $_donor_found -eq 0 ]]; then
      echo "==> WARN: No Color Icon donor found for HDToolBox.info"
    fi
  fi

  rm -f "$_probe" "$_icon_tmp" 2>/dev/null || true
  if [[ $have_binary -eq 1 ]]; then
    echo "==> HDToolBox found in SYS:Tools (icon: $([ $have_icon -eq 1 ] && echo 'yes' || echo 'MISSING'))"
  fi
}

# create_hdtoolbox_pi_variants()
#   Create HDToolboxPi3.info and HDToolboxPi4.info from HDToolBox.info with
#   correct SCSI device tooltypes for each Pi model.
#   Pi4vsPi3_Pistorm renames the matching variant to HDToolbox.info at first boot.
#   $1 = sys_root

# ===========================================================================
# Color Icons borderless patching
# ===========================================================================
set_coloricons_borderless_host() {
  local host_dir="$1"
  [[ -d "$host_dir" ]] || return 0

  echo "==> Setting Color Icons borderless in $(basename "$host_dir")"
  "$AMIGA_TOOLS" patch-info-borderless-dir "$host_dir" 2>/dev/null \
    || echo "==> Color Icons: amiga-tools not available (skipping borderless)"
}

# set_coloricons_borderless_image()
#   Same as above but operates on .info files inside an RDB image partition.
#   Only processes icons in specified subdirectories to keep it efficient.
#   $1 = sys_root (image path)
#   Remaining args = subdirectories to process (e.g. "Tools" "System" "Prefs")
set_coloricons_borderless_image() {
  local sys_root="$1"
  shift
  local _dirs=("$@")
  local _patched=0 _total=0
  local _tmp_info="$STAGE/.borderless-img.info"

  for _subdir in "${_dirs[@]}"; do
    local _listing
    _listing="$(fs_list_recursive "$sys_root/$_subdir" 2>/dev/null || true)"
    while IFS= read -r _line; do
      # Parse listing line. Native engine outputs one relative path per line.
      # hst-imager outputs pipe-delimited rows "Name | Size | ..."; extract first field.
      local _name
      if [[ "$_line" == *"|"* ]]; then
        _name="$(echo "$_line" | sed 's/|.*//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')"
      else
        _name="$_line"
      fi
      echo "$_name" | grep -qiE '\.info$' || continue
      _total=$((_total + 1))

      rm -f "$_tmp_info" 2>/dev/null || true
      fs_pull_file "$sys_root/$_subdir/$_name" "$_tmp_info" 2>/dev/null || continue

      # patch-info-borderless: exit 0 = patched, exit non-0 = no change needed
      "$AMIGA_TOOLS" patch-info-borderless "$_tmp_info" 2>/dev/null || continue
      fs_copy_file "$_tmp_info" "$sys_root/$_subdir/$_name" 2>/dev/null || continue
      _patched=$((_patched + 1))
    done <<< "$_listing"
  done
  rm -f "$_tmp_info" 2>/dev/null || true
  [[ $_total -gt 0 ]] && echo "==> Color Icons (image): $_patched/$_total set to borderless" || true
}

# _set_borderless_image_file()
#   Applies the borderless patch to a single named .info file on the image.
#   Silent no-op if the file doesn't exist or the operation fails.
#   $1 = sys_root, $2 = image-relative path (e.g. "Programs/ScummVM.info")
_set_borderless_image_file() {
  local sys_root="$1" img_path="$2"
  local _tmp="$STAGE/.borderless-single.info"
  rm -f "$_tmp" 2>/dev/null || true
  fs_pull_file "$sys_root/$img_path" "$_tmp" 2>/dev/null || return 0
  "$AMIGA_TOOLS" patch-info-borderless "$_tmp" 2>/dev/null || { rm -f "$_tmp" 2>/dev/null || true; return 0; }
  fs_copy_file "$_tmp" "$sys_root/$img_path" 2>/dev/null || true
  rm -f "$_tmp" 2>/dev/null || true
}

# NOTE: This normalization must match the AWK norm() function in
# install_aminet_package_buildtime(). Both strip non-alphanumeric chars
# so that package name matching is case/punctuation-insensitive.

# ===========================================================================
# Aminet CSV helpers
# ===========================================================================
normalize_csv_token() {
  local s="${1:-}"
  s="$(printf "%s" "$s" | tr '[:upper:]' '[:lower:]')"
  s="$(printf "%s" "$s" | tr -cd 'a-z0-9')"
  printf "%s" "$s"
}

resolve_aminet_rules_csv() {
  if [[ -n "$AMINET_RULES_CSV_RESOLVED" ]]; then
    [[ "$AMINET_RULES_CSV_RESOLVED" == "__none__" ]] && return 1
    echo "$AMINET_RULES_CSV_RESOLVED"
    return 0
  fi

  local cand=""
  if [[ -n "$AMINET_RULES_CSV" ]]; then
    [[ -f "$AMINET_RULES_CSV" ]] || return 1
    cand="$AMINET_RULES_CSV"
  else
    cand="$(find_input_csv "ListofPackagestoInstall.CSV" || true)"
  fi

  if [[ -n "$cand" ]]; then
    AMINET_RULES_CSV_RESOLVED="$cand"
    echo "$cand"
    return 0
  fi

  AMINET_RULES_CSV_RESOLVED="__none__"
  return 1
}

aminet_package_hint_for_archive() {
  local lha_base="$1"
  local lower
  lower="$(printf '%s' "$lha_base" | tr '[:upper:]' '[:lower:]')"
  case "$lower" in
    roadie.lha) echo "Roadie" ;;
    mui38usr.lha) echo "MUI38" ;;
    hippoplayer.lha) echo "HippoPlayer" ;;
    imp3.lha) echo "IMP3" ;;
    picasso96.lha) echo "Picasso96" ;;
    reqtoolsusr.lha|reqtoolswide.lha|reqtools-wide.lha) echo "ReqTools" ;;
    guigfxlib.lha) echo "guigfxlib" ;;
    renderlib.lha) echo "renderlib" ;;
    toolsdaemon21a.lha) echo "ToolsDaemon" ;;
    sysinfo.lha) echo "SysInfo" ;;
    dopus414jrbin.lha|dopus416jrbin.lha) echo "DOpus416" ;;
    whdload_usr.lha|whdload-user.lha|whdload.lha) echo "WHDLoad" ;;
    remlib.lha) echo "RemLib" ;;
    cpublit.lha) echo "CpuBlit" ;;
    patchram.lha) echo "PatchRAM" ;;
    noclick.lha) echo "NoClick" ;;
    copyicon44.lha) echo "CopyIcon44" ;;
    lha_68040.lha|lha_68020.lha|lha_68k.lha) echo "LhA" ;;
    ahiusr_4.18.lha) echo "AHI" ;;
    amigaamp.lha|amigaamp3-68k.lha) echo "AmigaAMP" ;;
    riva-0.54.lha) echo "RiVA" ;;
    stackattack2.lha) echo "StackAttack" ;;
    asyncio.lha) echo "asyncio" ;;
    mpega_library.lha) echo "mpega" ;;
    poseidonmain.lha) echo "Poseidon" ;;
    akpng-dt.lha) echo "akPNG" ;;
    akgif-dt.lha) echo "akGIF" ;;
    akjfif-dt.lha) echo "akJFIF" ;;
    tankmouse.lha) echo "TankMouse" ;;
    hid2ami.lha) echo "Hid2Ami" ;;
    ryswheel_installer_v0.14.2.lha) echo "RysMKII" ;;
    igame.lha) echo "iGame" ;;
    lumiftp_1.0.201.lha|lumiftp_1.0.258.lha|lumiftp_1.1.lha) echo "LumiFTP" ;;
    solas.lha) echo "Solas" ;;
    lumiweather_1.0.lha) echo "LumiWeather" ;;
    lumipass_1.1.lha) echo "LumiPass" ;;
    lumireg_1.1.lha) echo "LumiReg" ;;
    wbdock2.lha) echo "WBDock2" ;;
    iconlib_46.4.lha) echo "IconLib" ;;
    xadmaster020.lha) echo "xadmaster" ;;
    uhctools.lha) echo "UHCTools" ;;
    newmeter.lha) echo "NewMeter" ;;
    *) return 1 ;;
  esac
}

# install_mpega_library <sys_root> <lha_file> <cpu_class>
#   Extracts the CPU-appropriate variant from mpega_library.lha and installs
#   it as SYS:Libs/mpega.library.
#
#   cpu_class:
#     "fpu"  → mpega040FPU.library  (68040/060 with FPU — Emu68, hardware 040/060)
#     "040"  → mpega040.library     (68040 integer only — EC040 without FPU)
#     "020"  → mpega020.library     (68020/030 — no FPU required)
#     "000"  → skip (68000 — mpega.library requires 68020+)
install_mpega_library() {
  local sys_root="$1"
  local lha_file="$2"
  local cpu_class="${3:-020}"
  local lha_base
  lha_base="$(basename "$lha_file")"

  if [[ "$cpu_class" == "000" ]]; then
    echo "==> mpega.library: skipping (requires 68020+, target is 68000)"
    return 0
  fi

  local tmp_extract="$STAGE/mpega-extract-$$"
  rm -rf "$tmp_extract" 2>/dev/null || true
  mkdir -p "$tmp_extract"

  # Try lha first (hst-imager IndexOutOfRangeException bug on Intel macOS x64 LHA extraction).
  local _lha_mpega=""
  _lha_mpega="$(find_lha)" || true
  if [[ -n "$_lha_mpega" ]]; then
    (cd "$tmp_extract" && "$_lha_mpega" x "$lha_file") 2>/dev/null || true
  fi
  if [[ -z "$(find "$tmp_extract" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
    if ! "$HST_IMAGER" fs extract "$lha_file" "$tmp_extract" --recursive --force 2>/dev/null; then
      if ! "$HST_IMAGER" fs extract "$lha_file" "$tmp_extract" --recursive 2>/dev/null; then
        echo "    WARN: Failed to extract $lha_base"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
      fi
    fi
  fi

  local variant_file
  case "$cpu_class" in
    fpu) variant_file="mpega040FPU.library" ;;
    040) variant_file="mpega040.library" ;;
    *)   variant_file="mpega020.library" ;;
  esac

  local src_lib
  src_lib="$(find "$tmp_extract" -name "$variant_file" -type f 2>/dev/null | head -1)"
  if [[ -z "$src_lib" ]]; then
    echo "    WARN: $lha_base: $variant_file not found in archive"
    rm -rf "$tmp_extract" 2>/dev/null || true
    return 1
  fi

  chmod +x "$src_lib" 2>/dev/null || true
  if fs_copy_file "$src_lib" "$sys_root/Libs/mpega.library"; then
    echo "==> mpega.library: installed $variant_file (cpu_class=$cpu_class)"
  else
    echo "    WARN: mpega.library: failed to copy to SYS:Libs"
    rm -rf "$tmp_extract" 2>/dev/null || true
    return 1
  fi

  rm -rf "$tmp_extract" 2>/dev/null || true
}

# ===========================================================================
# FastFileSystem finder
# ===========================================================================
find_ffs_in_adf_dir() {
  # Try to locate FastFileSystem by extracting ADFs to a temp host folder.
  # Echoes a host path to the discovered binary on success.
  local adf_dir="$1"
  [[ -d "$adf_dir" ]] || return 1

  local tmp_ffs_root="$STAGE/ffs-scan"
  rm -rf "$tmp_ffs_root" >/dev/null 2>&1 || true
  mkdir -p "$tmp_ffs_root"

  local -a adf_candidates=()
  local _f

  # Prefer Workbench/Install disks first, then try all ADFs.
  for _f in "$adf_dir"/Workbench*.adf "$adf_dir"/Workbench*.ADF "$adf_dir"/Install*.adf "$adf_dir"/Install*.ADF; do
    [[ -f "$_f" ]] && adf_candidates+=("$_f")
  done
  for _f in "$adf_dir"/*.adf "$adf_dir"/*.ADF; do
    [[ -f "$_f" ]] && adf_candidates+=("$_f")
  done

  # De-duplicate while preserving order.
  local -a deduped=()
  local seen="|"
  local item
  for item in "${adf_candidates[@]}"; do
    if [[ "$seen" == *"|$item|"* ]]; then
      continue
    fi
    deduped+=("$item")
    seen="${seen}${item}|"
  done

  local adf
  for adf in "${deduped[@]}"; do
    local adf_out="$tmp_ffs_root/$(basename "$adf").extract"
    rm -rf "$adf_out" >/dev/null 2>&1 || true
    mkdir -p "$adf_out"

    if ! "$HST_IMAGER" fs extract "$adf" "$adf_out" --recursive --force >/dev/null 2>&1 \
      && ! "$HST_IMAGER" fs extract "$adf" "$adf_out" --recursive >/dev/null 2>&1 \
      && ! "$HST_IMAGER" fs copy "$adf" "$adf_out" --recursive --force >/dev/null 2>&1 \
      && ! "$HST_IMAGER" fs copy "$adf" "$adf_out" --recursive >/dev/null 2>&1
    then
      continue
    fi

    local cand
    for cand in \
      "$adf_out/L/FastFileSystem" \
      "$adf_out/L/fastfilesystem" \
      "$adf_out/FastFileSystem" \
      "$adf_out/fastfilesystem"
    do
      if [[ -f "$cand" ]]; then
        local host_ffs="$tmp_ffs_root/FastFileSystem"
        cp -f "$cand" "$host_ffs" >/dev/null 2>&1 || host_ffs="$cand"
        echo "$host_ffs"
        return 0
      fi
    done
  done

  return 1
}

# ===========================================================================
# ToolType helpers (amiga-tools wrappers)
# ===========================================================================
# --- ToolTypes modification helpers (Windows parity) --------------------------

# export_tooltypes()
#   Export .info file tooltypes to a text file using amiga-tools.
#   $1 = info_path   (path to .info file, can be host or image path)
#   $2 = output_file (host path for exported tooltypes)
export_tooltypes() {
  local info_path="$1"
  local output_file="$2"
  "$AMIGA_TOOLS" icon-export-tooltypes "$info_path" "$output_file" 2>/dev/null
}

# import_tooltypes()
#   Import tooltypes from a text file into an .info file using amiga-tools.
#   $1 = info_path   (path to .info file)
#   $2 = input_file  (host path with tooltype lines)
import_tooltypes() {
  local info_path="$1"
  local input_file="$2"
  "$AMIGA_TOOLS" icon-import-tooltypes "$info_path" "$input_file" 2>/dev/null
}

# modify_tooltype()
#   High-level: export tooltypes, modify/add a key=value, import back.
#   $1 = info_path   (path to .info file)
#   $2 = tt_name     (tooltype name, e.g. "DONOTWAIT")
#   $3 = tt_value    (value, or empty for flag-style tooltypes)
modify_tooltype() {
  local info_path="$1"
  local tt_name="$2"
  local tt_value="${3:-}"

  local tmp_tt="$STAGE/tt-work.txt"

  if ! export_tooltypes "$info_path" "$tmp_tt"; then
    echo "==> WARN: Failed to export tooltypes from $info_path"
    return 1
  fi

  local line="$tt_name"
  [[ -n "$tt_value" ]] && line="${tt_name}=${tt_value}"

  if grep -qi "^${tt_name}" "$tmp_tt" 2>/dev/null; then
    # Replace existing
    "$AMIGA_TOOLS" replace-line-prefix "$tmp_tt" "${tt_name}" "${line}"
  else
    # Append new
    printf "%s\n" "$line" >> "$tmp_tt"
  fi

  if ! import_tooltypes "$info_path" "$tmp_tt"; then
    echo "==> WARN: Failed to import tooltypes for $info_path"
    return 1
  fi

  echo "==> Modified tooltype: $info_path -> $line"
  rm -f "$tmp_tt" 2>/dev/null || true
}

# replace_tooltype_line()
#   Replace an exact tooltype line with a new value.  Handles parenthesized
#   (disabled) ToolTypes that modify_tooltype() can't match by key prefix.
#   $1 = info_path   (path to .info file, can be host or image path)
#   $2 = old_line     (exact line to find, e.g. "(FORCEOPENXAD)")
#   $3 = new_line     (replacement line, e.g. "FORCEOPENXAD")
replace_tooltype_line() {
  local info_path="$1"
  local old_line="$2"
  local new_line="$3"

  local tmp_tt="$STAGE/tt-replace.txt"

  if ! export_tooltypes "$info_path" "$tmp_tt"; then
    echo "==> WARN: Failed to export tooltypes from $info_path"
    return 1
  fi

  if grep -qF "$old_line" "$tmp_tt" 2>/dev/null; then
    sed -i '' "s|$(printf '%s' "$old_line" | sed 's|[&/\]|\\&|g')|${new_line}|g" "$tmp_tt"
    if import_tooltypes "$info_path" "$tmp_tt"; then
      echo "==> Replaced tooltype: $info_path -> '$old_line' => '$new_line'"
    else
      echo "==> WARN: Failed to import tooltypes for $info_path"
    fi
  else
    echo "==> Tooltype '$old_line' not found in $info_path (skipping)"
  fi

  rm -f "$tmp_tt" 2>/dev/null || true
}

# apply_dopus_tooltype_fix()
#   Enable the FORCEOPENXAD ToolType in DirectoryOpus.info (Windows parity).
#   $1 = sys_root
apply_dopus_tooltype_fix() {
  local sys_root="$1"
  local info_img="$sys_root/Programs/DOpus/DirectoryOpus.info"
  local tmp_info="$STAGE/DirectoryOpus.info"

  if ! fs_pull_file "$info_img" "$tmp_info" 2>/dev/null; then
    echo "==> DOpus ToolType fix: DirectoryOpus.info not found in image (skipping)"
    return 0
  fi

  replace_tooltype_line "$tmp_info" "(FORCEOPENXAD)" "FORCEOPENXAD"
  fs_copy_file "$tmp_info" "$info_img"
  rm -f "$tmp_info" 2>/dev/null || true
}

# download_and_install_clicon()
#   Download CLICon from Aminet and install to SYS:System/.
#   CLICon (Thomas Richter) is an IconX replacement needed by PiStorm tools
#   (OnlineWifi, OnlineGENet, Offline) which use CLICon ToolTypes for script execution.
#   $1 = sys_root

# ===========================================================================
# External package downloads
# ===========================================================================
download_and_install_clicon() {
  local sys_root="$1"

  # Skip if CLICon already exists on image (e.g. from HD install media)
  if fs_dir "$sys_root/System/CLICon"; then
    echo "==> CLICon: already present in System/"
    return 0
  fi

  echo "==> CLICon: downloading from Aminet (needed by PiStorm tools)"
  local clicon_url="https://aminet.net/util/wb/CLICon.lha"
  local clicon_lha="$STAGE/CLICon.lha"

  if ! curl -fL --connect-timeout 10 --max-time 30 --retry 2 --retry-delay 2 \
       -o "$clicon_lha" "$clicon_url" 2>/dev/null; then
    echo "==> WARN: CLICon download failed; PiStorm Online/Offline tools will not work"
    return 0
  fi

  local tmp_cc="$STAGE/clicon-extract"
  rm -rf "$tmp_cc" 2>/dev/null || true
  mkdir -p "$tmp_cc"

  "$HST_IMAGER" fs extract "$clicon_lha" "$tmp_cc" --recursive --force 2>/dev/null || \
  "$HST_IMAGER" fs extract "$clicon_lha" "$tmp_cc" --recursive 2>/dev/null || {
    echo "==> WARN: Failed to extract CLICon LHA"
    rm -rf "$tmp_cc" 2>/dev/null || true
    return 0
  }

  local clicon_bin=""
  clicon_bin="$(find "$tmp_cc" -type f -name "CLICon" 2>/dev/null | head -n1)"
  if [[ -n "$clicon_bin" && -f "$clicon_bin" ]]; then
    ensure_img_dir "$sys_root/System"
    fs_copy_file "$clicon_bin" "$sys_root/System/CLICon"
    echo "==> CLICon installed to System/CLICon"
  else
    echo "==> WARN: CLICon binary not found in extracted archive"
  fi

  rm -rf "$tmp_cc" "$clicon_lha" 2>/dev/null || true
}

# install_acatool_for_classic()
#   Download iComp ACATool, install to C:, copy user's Kickstart ROM to
#   Devs/Kickstarts/, and inject MapROM activation into Startup-Sequence.
#   This activates hardware MapROM on iComp ACA cards (1221/1232/1233n/1234)
#   so a newer Kickstart ROM file is mapped into Fast RAM at boot.
#   $1 = sys_root
#   $2 = host path to user's Kickstart ROM file
#   $3 = ACA card name (e.g. ACA1234)
install_acatool_for_classic() {
  local sys_root="$1"
  local kick_rom_host="$2"
  local card_name="$3"

  [[ -f "$kick_rom_host" ]] || { echo "==> WARN: ACATool: Kickstart ROM not found: $kick_rom_host"; return 0; }

  # Download ACATool from iComp wiki
  local acatool_url="https://wiki.icomp.de/w/images/e/ed/Acatool-3.5.lha"
  local acatool_lha="$STAGE/Acatool-3.5.lha"
  echo "==> ACATool: downloading from iComp ($acatool_url)"
  if ! curl -fL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 \
       -o "$acatool_lha" "$acatool_url" 2>/dev/null; then
    echo "==> WARN: ACATool download failed; MapROM activation will not be installed"
    return 0
  fi

  # Extract LHA
  local tmp_at="$STAGE/acatool-extract"
  rm -rf "$tmp_at" 2>/dev/null || true
  mkdir -p "$tmp_at"
  "$HST_IMAGER" fs extract "$acatool_lha" "$tmp_at" --recursive --force 2>/dev/null || \
  "$HST_IMAGER" fs extract "$acatool_lha" "$tmp_at" --recursive 2>/dev/null || {
    echo "==> WARN: Failed to extract ACATool LHA"
    rm -rf "$tmp_at" "$acatool_lha" 2>/dev/null || true
    return 0
  }

  # Find acatool binary (case-insensitive search)
  local acatool_bin=""
  acatool_bin="$(find "$tmp_at" -type f -iname "acatool" 2>/dev/null | head -n1)"
  if [[ -z "$acatool_bin" || ! -f "$acatool_bin" ]]; then
    echo "==> WARN: ACATool binary not found in archive"
    rm -rf "$tmp_at" "$acatool_lha" 2>/dev/null || true
    return 0
  fi

  # Install acatool to C:
  ensure_img_dir "$sys_root/C"
  fs_copy_file "$acatool_bin" "$sys_root/C/acatool"
  echo "==> ACATool: installed to C:acatool"

  # Copy Kickstart ROM to Devs/Kickstarts/ for MapROM source
  local kick_name
  kick_name="$(basename "$kick_rom_host")"
  ensure_img_dir "$sys_root/Devs/Kickstarts"
  fs_copy_file "$kick_rom_host" "$sys_root/Devs/Kickstarts/$kick_name"
  echo "==> ACATool: Kickstart ROM copied to Devs/Kickstarts/$kick_name"

  # Patch Startup-Sequence:
  # 1. Comment out all LoadModule calls (not needed — acatool does MapROM instead)
  # 2. Inject acatool MAPROM after SetPatch (requires mmu.library to be initialized)
  # 3. Comment out "Version ram-handler 47" check (acatool handles ROM version)
  local _ss_tmp="$STAGE/ss-acatool-mapron.$$"
  if fs_pull_file "$sys_root/S/Startup-Sequence" "$_ss_tmp"; then
    make_lf_only "$_ss_tmp"
    local _ss_patched="$_ss_tmp.patched"
    awk -v card="$card_name" -v rom="$kick_name" '
      # Comment out LoadModule lines — acatool handles ROM mapping on ACA cards
      /^[[:space:]]*LoadModule/ {
        print "; " $0 " (disabled by Amiga Imager — ACATool handles MapROM)"
        next
      }
      # Comment out ram-handler check — acatool validates ROM itself
      /^Version ram-handler 47/ { print "; " $0 " (disabled by Amiga Imager)"; in_rh_block=1; next }
      in_rh_block && /^EndIf/ { print "; " $0; in_rh_block=0; next }
      in_rh_block { print "; " $0; next }
      # Inject acatool MAPROM after SetPatch
      { print }
      /^SetPatch/ && !mapron_done {
        print ""
        print "; iComp " card " MapROM activation — Amiga Imager"
        print "Version exec.library version 47 >NIL:"
        print "If Warn"
        print "  Echo \"\""
        print "  Echo \" Activating " card " MapROM with V47 Kickstart...\""
        print "  Echo \" System will reboot automatically.\""
        print "  Echo \"\""
        print "  C:acatool INFO"
        print "  ; NOMMULIB tells acatool to NOT auto-load mmu.library from LIBS:."
        print "  ; Our build installs mmu.library for the general 68030+ case, but"
        print "  ; the mmu.library workaround path inside acatool collides with"
        print "  ; ACA1234 MapROM + KS 3.1 ROM and grey-screens the machine on the"
        print "  ; second boot after warm reset. See acatool 3.5 README."
        print "  C:acatool MAPROM ROMFILE=DEVS:Kickstarts/" rom " FORCE NOERRORS NOMMULIB"
        print "  Echo \" If you see this, acatool did not reboot.\""
        print "EndIf"
        print ""
        mapron_done=1
      }
    ' "$_ss_tmp" > "$_ss_patched"
    chmod +x "$_ss_patched"  # Amiga 's' bit: Startup-Sequence is an AmigaDOS script
    fs_copy_file "$_ss_patched" "$sys_root/S/Startup-Sequence"
    rm -f "$_ss_tmp" "$_ss_patched"
    echo "==> ACATool: MapROM activation injected, LoadModule calls disabled"
  fi

  rm -rf "$tmp_at" "$acatool_lha" 2>/dev/null || true
}

# install_zz9000_for_classic()
#   Download MNT ZZ9000 installer from mntre.com and install the full driver set:
#   - Libs/Picasso96/ZZ9000.card      (P96 card driver)
#   - Devs/Picasso96Settings           (P96 board config)
#   - Devs/Monitors/ZZ9000            (P96 monitor binary, from p96-extract stage dir)
#   - Devs/Monitors/ZZ9000.info       (P96 monitor icon, required to silence P96 warning)
#   - Devs/AHI/ZZ9000AX.audio         (AHI audio driver)
#   - Devs/AudioModes/ZZ9000AX        (AHI audio mode definition)
#   - Libs/MHI/mhizz9000.library      (Music Handler Interface)
#   - Devs/Networks/ZZ9000Net.device   (SANA-II network driver)
#   - Devs/NetInterfaces/ZZ9000Net     (Roadshow interface template)
#   - Programs/ZZTop + .info           (config/diagnostic utility)
#   $1 = sys_root
install_zz9000_for_classic() {
  local sys_root="$1"

  # Download ZZ9000 installer LHA from MNT
  local zz_url="https://mntre.com/media/ZZ9000_info_md/ZZ9000Installer1_13.lha"
  local zz_lha="$STAGE/ZZ9000Installer1_13.lha"
  echo "==> ZZ9000: downloading installer from MNT ($zz_url)"
  if ! curl -fL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 \
       -o "$zz_lha" "$zz_url" 2>/dev/null; then
    echo "==> WARN: ZZ9000 installer download failed; ZZ9000 will not be installed"
    return 0
  fi

  # Extract LHA
  local tmp_zz="$STAGE/zz9000-extract"
  rm -rf "$tmp_zz" 2>/dev/null || true
  mkdir -p "$tmp_zz"
  "$HST_IMAGER" fs extract "$zz_lha" "$tmp_zz" --recursive --force 2>/dev/null || \
  "$HST_IMAGER" fs extract "$zz_lha" "$tmp_zz" --recursive 2>/dev/null || {
    echo "==> WARN: Failed to extract ZZ9000 installer"
    rm -rf "$tmp_zz" "$zz_lha" 2>/dev/null || true
    return 0
  }

  # --- P96 card driver ---
  local zz_card=""
  zz_card="$(find "$tmp_zz" -type f -iname 'ZZ9000.card' 2>/dev/null | head -n1)"
  if [[ -z "$zz_card" || ! -f "$zz_card" ]]; then
    echo "==> WARN: ZZ9000.card not found in installer archive"
    rm -rf "$tmp_zz" "$zz_lha" 2>/dev/null || true
    return 0
  fi
  ensure_img_dir "$sys_root/Libs/Picasso96"
  fs_copy_file "$zz_card" "$sys_root/Libs/Picasso96/ZZ9000.card"
  echo "==> ZZ9000: installed Libs/Picasso96/ZZ9000.card"

  # --- P96 board settings ---
  local zz_settings=""
  zz_settings="$(find "$tmp_zz" -type f -iname 'Picasso96Settings' 2>/dev/null | head -n1)"
  if [[ -n "$zz_settings" && -f "$zz_settings" ]]; then
    fs_copy_file "$zz_settings" "$sys_root/Devs/Picasso96Settings"
    echo "==> ZZ9000: installed Devs/Picasso96Settings"
  fi

  # --- P96 monitor + icon ---
  # ZZ9000 uses the generic Picasso96 monitor binary placed as Devs/Monitors/ZZ9000.
  # Both the binary and the .info icon are needed.
  local _zz_mon="" _zz_mon_info=""
  if [[ -d "$STAGE/p96-monitor-cache" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      [[ -f "$STAGE/p96-monitor-cache/$_try_mon" ]] || continue
      _zz_mon="$STAGE/p96-monitor-cache/$_try_mon"
      [[ -f "$STAGE/p96-monitor-cache/${_try_mon}.info" ]] && _zz_mon_info="$STAGE/p96-monitor-cache/${_try_mon}.info"
      break
    done
  fi
  # Primary: source from p96-extract stage dir, but prefer the generic
  # Picasso96 donor before Native/Generic aliases to match the shipped path.
  if [[ -z "$_zz_mon" && -d "$STAGE/p96-extract" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      _zz_mon="$(find "$STAGE/p96-extract" -type f -iname "$_try_mon" -path '*/monitors/*' 2>/dev/null | head -1)"
      [[ -n "$_zz_mon" ]] || continue
      [[ -f "${_zz_mon}.info" ]] && _zz_mon_info="${_zz_mon}.info"
      break
    done
  fi
  # Fallback: pull from image.
  if [[ -z "$_zz_mon" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      fs_dir "$sys_root/Devs/Monitors/$_try_mon" || continue
      local _tmp_mon="$STAGE/zz9000-monitor.bin"
      fs_pull_file "$sys_root/Devs/Monitors/$_try_mon" "$_tmp_mon" >/dev/null 2>&1 || continue
      _zz_mon="$_tmp_mon"
      local _tmp_mon_info="$STAGE/zz9000-monitor.info"
      if fs_pull_file "$sys_root/Devs/Monitors/${_try_mon}.info" "$_tmp_mon_info" >/dev/null 2>&1; then
        _zz_mon_info="$_tmp_mon_info"
      fi
      break
    done
  fi
  if [[ -n "$_zz_mon" && -f "$_zz_mon" ]]; then
    ensure_img_dir "$sys_root/Devs/Monitors"
    fs_copy_file "$_zz_mon" "$sys_root/Devs/Monitors/ZZ9000"
    echo "==> ZZ9000: installed Devs/Monitors/ZZ9000"
    if [[ -n "$_zz_mon_info" && -f "$_zz_mon_info" ]]; then
      fs_copy_file "$_zz_mon_info" "$sys_root/Devs/Monitors/ZZ9000.info"
      echo "==> ZZ9000: installed Devs/Monitors/ZZ9000.info"
    fi
    rm -f "$STAGE/zz9000-monitor.bin" "$STAGE/zz9000-monitor.info" 2>/dev/null || true
  else
    echo "==> WARN: ZZ9000: no P96 monitor binary found to create Devs/Monitors/ZZ9000"
  fi

  # --- AHI audio driver ---
  local zz_ahi=""
  zz_ahi="$(find "$tmp_zz" -type f -iname 'ZZ9000AX.audio' 2>/dev/null | head -n1)"
  if [[ -n "$zz_ahi" && -f "$zz_ahi" ]]; then
    ensure_img_dir "$sys_root/Devs/AHI"
    fs_copy_file "$zz_ahi" "$sys_root/Devs/AHI/ZZ9000AX.audio"
    echo "==> ZZ9000: installed Devs/AHI/ZZ9000AX.audio"
  fi

  # --- AHI audio mode ---
  local zz_audiomode=""
  zz_audiomode="$(find "$tmp_zz" -type f -iname 'ZZ9000AX' -path '*/AudioModes/*' 2>/dev/null | head -n1)"
  if [[ -n "$zz_audiomode" && -f "$zz_audiomode" ]]; then
    ensure_img_dir "$sys_root/Devs/AudioModes"
    fs_copy_file "$zz_audiomode" "$sys_root/Devs/AudioModes/ZZ9000AX"
    echo "==> ZZ9000: installed Devs/AudioModes/ZZ9000AX"
  fi

  # --- MHI audio library ---
  local zz_mhi=""
  zz_mhi="$(find "$tmp_zz" -type f -iname 'mhizz9000.library' 2>/dev/null | head -n1)"
  if [[ -n "$zz_mhi" && -f "$zz_mhi" ]]; then
    ensure_img_dir "$sys_root/Libs/MHI"
    fs_copy_file "$zz_mhi" "$sys_root/Libs/MHI/mhizz9000.library"
    echo "==> ZZ9000: installed Libs/MHI/mhizz9000.library"
  fi

  # --- SANA-II network driver ---
  local zz_netdev=""
  zz_netdev="$(find "$tmp_zz" -type f -iname 'ZZ9000Net.device' 2>/dev/null | head -n1)"
  if [[ -n "$zz_netdev" && -f "$zz_netdev" ]]; then
    ensure_img_dir "$sys_root/Devs/Networks"
    fs_copy_file "$zz_netdev" "$sys_root/Devs/Networks/ZZ9000Net.device"
    echo "==> ZZ9000: installed Devs/Networks/ZZ9000Net.device"
  fi

  # --- Roadshow interface template ---
  local zz_netif=""
  zz_netif="$(find "$tmp_zz" -type f -iname 'ZZ9000Net' -path '*/NetInterfaces/*' 2>/dev/null | head -n1)"
  if [[ -n "$zz_netif" && -f "$zz_netif" ]]; then
    ensure_img_dir "$sys_root/Storage/NetInterfaces"
    fs_copy_file "$zz_netif" "$sys_root/Storage/NetInterfaces/ZZ9000Net"
    echo "==> ZZ9000: installed Storage/NetInterfaces/ZZ9000Net (Roadshow template)"
  fi

  # --- ZZTop utility ---
  local zz_top=""
  zz_top="$(find "$tmp_zz" -type f -iname 'ZZTop' -not -iname '*.info' 2>/dev/null | head -n1)"
  if [[ -n "$zz_top" && -f "$zz_top" ]]; then
    ensure_img_dir "$sys_root/Programs"
    fs_copy_file "$zz_top" "$sys_root/Programs/ZZTop"
    echo "==> ZZ9000: installed Programs/ZZTop"
    local zz_top_info="${zz_top}.info"
    if [[ -f "$zz_top_info" ]]; then
      fs_copy_file "$zz_top_info" "$sys_root/Programs/ZZTop.info"
    fi
  fi

  rm -rf "$tmp_zz" "$zz_lha" 2>/dev/null || true
}

# install_va2000_for_classic()
#   Download MNT VA2000 driver package from mntre.com and install the full driver set:
#   - Libs/Picasso96/VA2000.card      (P96 card driver)
#   - Devs/Picasso96Settings           (P96 board config, from Picasso96Settings.64)
#   - Devs/Monitors/VA2000            (P96 monitor binary, from p96-extract stage dir)
#   - Devs/Monitors/VA2000.info       (P96 monitor icon, required to silence P96 warning)
#   - Programs/va2000cx               (VA2000 control utility)
#   - C/RysWheel                      (input tool from RysMKII package, 7-bit.pl)
#   $1 = sys_root
install_va2000_for_classic() {
  local sys_root="$1"

  # Download VA2000 driver package from MNT
  local va_url="https://mntre.com/va2000/va2000-drivers-1.9.0beta2.lha"
  local va_lha="$STAGE/va2000-drivers-1.9.0beta2.lha"
  echo "==> VA2000: downloading driver package from MNT ($va_url)"
  if ! curl -fL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 \
       -o "$va_lha" "$va_url" 2>/dev/null; then
    echo "==> WARN: VA2000 driver download failed; VA2000 will not be installed"
    return 0
  fi

  # Extract LHA
  local tmp_va="$STAGE/va2000-extract"
  rm -rf "$tmp_va" 2>/dev/null || true
  mkdir -p "$tmp_va"
  "$HST_IMAGER" fs extract "$va_lha" "$tmp_va" --recursive --force 2>/dev/null || \
  "$HST_IMAGER" fs extract "$va_lha" "$tmp_va" --recursive 2>/dev/null || {
    echo "==> WARN: Failed to extract VA2000 driver package"
    rm -rf "$tmp_va" "$va_lha" 2>/dev/null || true
    return 0
  }

  # --- P96 card driver ---
  # Package v1.9.0beta2 renamed VA2000.card → mntgfx.card; search both names.
  local va_card=""
  va_card="$(find "$tmp_va" -type f \( -iname 'VA2000.card' -o -iname 'mntgfx.card' \) 2>/dev/null | head -n1)"
  if [[ -z "$va_card" || ! -f "$va_card" ]]; then
    echo "==> WARN: VA2000.card / mntgfx.card not found in driver package"
    rm -rf "$tmp_va" "$va_lha" 2>/dev/null || true
    return 0
  fi
  ensure_img_dir "$sys_root/Libs/Picasso96"
  fs_copy_file "$va_card" "$sys_root/Libs/Picasso96/VA2000.card"
  echo "==> VA2000: installed Libs/Picasso96/VA2000.card (from $(basename "$va_card"))"

  # --- P96 board settings ---
  local va_settings=""
  va_settings="$(find "$tmp_va" -type f \( -iname 'Picasso96Settings.64' -o -iname 'Picasso96Settings' \) 2>/dev/null | head -n1)"
  if [[ -n "$va_settings" && -f "$va_settings" ]]; then
    fs_copy_file "$va_settings" "$sys_root/Devs/Picasso96Settings"
    echo "==> VA2000: installed Devs/Picasso96Settings"
  fi

  # --- P96 monitor + icon ---
  # VA2000 uses the generic Picasso96 monitor binary placed as Devs/Monitors/VA2000.
  # P96 matches the monitor filename to the .card filename automatically.
  # Both the binary and the .info icon are needed; without the icon P96 warns
  # "Could not find monitor driver icon for 'VA2000'".
  local _va_mon="" _va_mon_info=""
  if [[ -d "$STAGE/p96-monitor-cache" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      [[ -f "$STAGE/p96-monitor-cache/$_try_mon" ]] || continue
      _va_mon="$STAGE/p96-monitor-cache/$_try_mon"
      [[ -f "$STAGE/p96-monitor-cache/${_try_mon}.info" ]] && _va_mon_info="$STAGE/p96-monitor-cache/${_try_mon}.info"
      break
    done
  fi
  # Primary: source directly from the p96-extract stage dir (works even when the
  # generic Devs/Monitors/Picasso96 is not installed in the image for this card),
  # but still prefer Picasso96 before Native/Generic aliases.
  if [[ -z "$_va_mon" && -d "$STAGE/p96-extract" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      _va_mon="$(find "$STAGE/p96-extract" -type f -iname "$_try_mon" -path '*/monitors/*' 2>/dev/null | head -n1)"
      [[ -n "$_va_mon" ]] || continue
      [[ -f "${_va_mon}.info" ]] && _va_mon_info="${_va_mon}.info"
      break
    done
  fi
  # Fallback: pull from the image (compatibility path if stage dir was cleaned).
  if [[ -z "$_va_mon" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      fs_dir "$sys_root/Devs/Monitors/$_try_mon" || continue
      local _tmp_mon="$STAGE/va2000-monitor.bin"
      fs_pull_file "$sys_root/Devs/Monitors/$_try_mon" "$_tmp_mon" >/dev/null 2>&1 || continue
      _va_mon="$_tmp_mon"
      local _tmp_mon_info="$STAGE/va2000-monitor.info"
      if fs_pull_file "$sys_root/Devs/Monitors/${_try_mon}.info" "$_tmp_mon_info" >/dev/null 2>&1; then
        _va_mon_info="$_tmp_mon_info"
      fi
      break
    done
  fi
  if [[ -n "$_va_mon" && -f "$_va_mon" ]]; then
    ensure_img_dir "$sys_root/Devs/Monitors"
    fs_copy_file "$_va_mon" "$sys_root/Devs/Monitors/VA2000"
    echo "==> VA2000: installed Devs/Monitors/VA2000"
    if [[ -n "$_va_mon_info" && -f "$_va_mon_info" ]]; then
      fs_copy_file "$_va_mon_info" "$sys_root/Devs/Monitors/VA2000.info"
      echo "==> VA2000: installed Devs/Monitors/VA2000.info"
    fi
    rm -f "$STAGE/va2000-monitor.bin" "$STAGE/va2000-monitor.info" 2>/dev/null || true
  else
    echo "==> WARN: VA2000: no P96 monitor binary found to create Devs/Monitors/VA2000"
  fi

  # --- va2000cx control utility ---
  local va_cx=""
  va_cx="$(find "$tmp_va" -type f -iname 'va2000cx' 2>/dev/null | head -n1)"
  if [[ -n "$va_cx" && -f "$va_cx" ]]; then
    ensure_img_dir "$sys_root/Programs"
    fs_copy_file "$va_cx" "$sys_root/Programs/va2000cx"
    echo "==> VA2000: installed Programs/va2000cx"
  fi

  rm -rf "$tmp_va" "$va_lha" 2>/dev/null || true

  # --- RysWheel input tool ---
  # Provides mouse/scrollwheel support for VA2000 hardware.
  # Try from AMINET_DIR first (user selected RysMKII package), then download.
  local _rw_lha=""
  if [[ -n "${AMINET_DIR:-}" && -f "$AMINET_DIR/RysWheel_Installer_v0.14.2.lha" ]]; then
    _rw_lha="$AMINET_DIR/RysWheel_Installer_v0.14.2.lha"
    echo "==> VA2000: RysWheel found in Aminet cache"
  else
    local _rw_dl="$STAGE/RysWheel_Installer_v0.14.2.lha"
    echo "==> VA2000: downloading RysWheel from 7-bit.pl"
    if curl -fL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 \
         -o "$_rw_dl" "https://www.7-bit.pl/download/RysMKII/RysWheel_Installer_v0.14.2.lha" 2>/dev/null; then
      _rw_lha="$_rw_dl"
    else
      echo "==> WARN: VA2000: RysWheel download failed; C:RysWheel will not be installed"
    fi
  fi
  if [[ -n "$_rw_lha" && -f "$_rw_lha" ]]; then
    local _rw_extract="$STAGE/ryswheel-extract"
    rm -rf "$_rw_extract" 2>/dev/null; mkdir -p "$_rw_extract"
    "$HST_IMAGER" fs extract "$_rw_lha" "$_rw_extract" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$_rw_lha" "$_rw_extract" --recursive 2>/dev/null || true
    local _rw_bin=""
    _rw_bin="$(find "$_rw_extract" -type f \( -iname 'RysWheel' -o -iname 'ryswheel' \) ! -name '*.info' 2>/dev/null | head -1)"
    if [[ -n "$_rw_bin" && -f "$_rw_bin" ]]; then
      ensure_img_dir "$sys_root/C"
      fs_copy_file "$_rw_bin" "$sys_root/C/RysWheel"
      echo "==> VA2000: installed C/RysWheel"
    else
      echo "==> WARN: VA2000: RysWheel binary not found in installer package"
    fi
    rm -rf "$_rw_extract" 2>/dev/null || true
    [[ "$_rw_lha" == "$STAGE/"* ]] && rm -f "$_rw_lha" 2>/dev/null || true
  fi
}

# install_pvision_for_classic()
#   Install Village Tronic P-Vision for A600/A1200 Classic builds:
#   - Libs/Picasso96/pVision.card     (driver from pVision.lha)
#   - Devs/Monitors/pVision           (copied from P96 generic monitor)
#   - Devs/Monitors/pVision.info      (copied from P96 generic monitor icon with BOARDTYPE=pVision)
#   - Devs/Picasso96Settings          (native P96 template, bundled P-Vision seed, or synthesized 0x50xx fallback)
#   Requires hst-amiga so the monitor icon ToolTypes can be patched reliably.
#   $1 = sys_root
normalize_pvision_settings_labels() {
  local settings_file="$1"
  [[ -f "$settings_file" ]] || return 1

  if ! have_cmd python3; then
    echo "==> WARN: P-Vision: python3 not available, leaving settings labels unchanged"
    return 1
  fi

  python3 - "$settings_file" <<'PY'
from pathlib import Path
import sys

path = Path(sys.argv[1])
data = bytearray(path.read_bytes())

board_name = b"pVision"
old_prefix = b"VideoCore:"
new_prefix = b"pVision:"

# Patch the board name in the first BDNM chunk.
bdnm = data.find(b"BDNM")
if bdnm != -1 and bdnm + 40 <= len(data):
    field_off = bdnm + 8
    data[field_off:field_off + 32] = board_name + b"\0" * (32 - len(board_name))

# Patch mode labels in every RSHD chunk so Classic P-Vision builds do not
# surface PiStorm/VideoCore names in P96Prefs.
patched = 0
pos = 0
while True:
    rshd = data.find(b"RSHD", pos)
    if rshd == -1:
        break
    field_off = rshd + 22
    field_end = rshd + 44
    if field_end > len(data):
        break
    label = bytes(data[field_off:field_end]).split(b"\0", 1)[0]
    if label.startswith(old_prefix):
        suffix = label[len(old_prefix):]
        new_label = new_prefix + suffix
        if len(new_label) <= (field_end - field_off):
            data[field_off:field_end] = new_label + b"\0" * ((field_end - field_off) - len(new_label))
            patched += 1
    pos = rshd + 4

path.write_bytes(data)
print(patched)
PY
}

install_pvision_for_classic() {
  local sys_root="$1"

  case "${CLASSIC_MACHINE:-}" in
    A600|A1200) ;;
    *)
      echo "==> WARN: P-Vision is only supported on A600/A1200 (selected: ${CLASSIC_MACHINE:-unknown})"
      return 1
      ;;
  esac

  local pv_lha=""
  if [[ -n "${AMINET_DIR:-}" && -d "${AMINET_DIR:-}" ]]; then
    for cand in "$AMINET_DIR"/pVision.lha "$AMINET_DIR"/PVision.lha "$AMINET_DIR"/pvision.lha; do
      [[ -f "$cand" ]] && pv_lha="$cand" && break
    done
    [[ -n "$pv_lha" ]] && echo "==> P-Vision: using driver archive from Aminet cache"
  fi
  if [[ -z "$pv_lha" && "${DOWNLOAD_ONLY:-0}" != "1" ]]; then
    for cand in \
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/pVision.lha" \
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/PVision.lha" \
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/pvision.lha"; do
      [[ -f "$cand" ]] && pv_lha="$cand" && break
    done
    [[ -n "$pv_lha" ]] && echo "==> P-Vision: using driver archive from LocalAmigaPackages"
  fi
  if [[ -z "$pv_lha" ]]; then
    local pv_url="https://aminet.net/driver/video/pVision.lha"
    local pv_dl="$STAGE/pVision.lha"
    echo "==> P-Vision: downloading driver archive from Aminet ($pv_url)"
    if ! curl -fL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 \
         -o "$pv_dl" "$pv_url" 2>/dev/null; then
      echo "==> WARN: P-Vision driver download failed"
      return 1
    fi
    pv_lha="$pv_dl"
  fi

  local tmp_pv="$STAGE/pvision-extract"
  rm -rf "$tmp_pv" 2>/dev/null || true
  mkdir -p "$tmp_pv"

  local _pv_extracted=0
  local _pv_lha_bin=""
  _pv_lha_bin="$(find_lha)" || true
  if [[ -n "$_pv_lha_bin" ]]; then
    (cd "$tmp_pv" && "$_pv_lha_bin" x "$pv_lha") || true
    if [[ -n "$(find "$tmp_pv" -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null)" ]]; then
      _pv_extracted=1
    fi
  fi
  if (( _pv_extracted == 0 )); then
    "$HST_IMAGER" fs extract "$pv_lha" "$tmp_pv" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$pv_lha" "$tmp_pv" --recursive 2>/dev/null || {
      echo "==> WARN: Failed to extract P-Vision archive"
      rm -rf "$tmp_pv" 2>/dev/null || true
      return 1
    }
  fi

  local pv_card=""
  pv_card="$(find "$tmp_pv" -type f -iname 'pVision.card' 2>/dev/null | head -n1)"
  if [[ -z "$pv_card" || ! -f "$pv_card" ]]; then
    echo "==> WARN: P-Vision card driver not found in archive"
    rm -rf "$tmp_pv" 2>/dev/null || true
    return 1
  fi
  ensure_img_dir "$sys_root/Libs/Picasso96"
  fs_copy_file "$pv_card" "$sys_root/Libs/Picasso96/pVision.card"
  echo "==> P-Vision: installed Libs/Picasso96/pVision.card"

  local _pv_settings="" _pv_settings_work="" _pv_settings_label=""
  local _pv_settings_src=""
  for _pv_settings_src in "$STAGE/p96-extract" "$STAGE/p96-settings-cache"; do
    [[ -d "$_pv_settings_src" ]] || continue
    for _settings_name in Picasso96Settings.pVision Picasso96Settings.PVision Picasso96Settings.pvision; do
      _pv_settings="$(find "$_pv_settings_src" -type f -iname "$_settings_name" 2>/dev/null | head -n1)"
      [[ -n "$_pv_settings" ]] && break
    done
    [[ -n "$_pv_settings" ]] && break
  done

  # Prefer a dedicated bundled P-Vision seed when the archive does not ship
  # native settings yet. This mirrors a known-good manual P96Prefs save from
  # real P-Vision hardware and carries a fuller mode database than the thin
  # generic VideoCore fallback.
  if [[ -z "$_pv_settings" ]]; then
    local _pv_seed_settings="$ASSETS_DIR/AmigaFiles/System/Devs/Picasso96Settings.pVision"
    if [[ -f "$_pv_seed_settings" ]]; then
      _pv_settings_work="$STAGE/pvision-settings"
      cp -f "$_pv_seed_settings" "$_pv_settings_work"
      _pv_settings="$_pv_settings_work"
      _pv_settings_label="bundled pVision seed"
    fi
  fi

  # Current iComp archives may not ship a dedicated P-Vision template yet.
  # If no native or bundled P-Vision settings are available, seed first-boot
  # RTG modes from our generic 0x50xx P96 template instead of borrowing
  # Visiona. Visiona settings carry 0x59xx ModeIDs and do not line up with
  # the generic 0x50xx screenmode prefs we write for modern RTG cards.
  if [[ -z "$_pv_settings" ]]; then
    local _pv_generic_settings="$ASSETS_DIR/AmigaFiles/System/Devs/Picasso96Settings"
    if [[ -f "$_pv_generic_settings" ]]; then
      _pv_settings_work="$STAGE/pvision-settings"
      cp -f "$_pv_generic_settings" "$_pv_settings_work"
      local _pv_bdnm_off=""
      _pv_bdnm_off="$(LC_ALL=C grep -aob 'BDNM' "$_pv_settings_work" | head -n1 | cut -d: -f1 || true)"
      if [[ -n "$_pv_bdnm_off" ]]; then
        local _pv_board_field="$STAGE/pvision-board-field"
        printf 'pVision' > "$_pv_board_field"
        dd if=/dev/zero bs=1 count=25 >> "$_pv_board_field" 2>/dev/null
        dd if="$_pv_board_field" of="$_pv_settings_work" \
          bs=1 seek="$((_pv_bdnm_off + 8))" conv=notrunc 2>/dev/null
        rm -f "$_pv_board_field" 2>/dev/null || true
        _pv_settings="$_pv_settings_work"
        _pv_settings_label="generic 0x50xx template"
      else
        echo "==> WARN: P-Vision: generic settings template did not contain a patchable BDNM chunk"
        rm -f "$_pv_settings_work" 2>/dev/null || true
        _pv_settings_work=""
      fi
    fi
  fi
  if [[ -n "$_pv_settings" && -f "$_pv_settings" ]]; then
    local _pv_label_patch_count=""
    _pv_label_patch_count="$(normalize_pvision_settings_labels "$_pv_settings" 2>/dev/null | tail -n1 || true)"
    case "$_pv_label_patch_count" in
      ''|*[!0-9]*) _pv_label_patch_count="" ;;
    esac
    ensure_img_dir "$sys_root/Devs"
    fs_copy_file "$_pv_settings" "$sys_root/Devs/Picasso96Settings"
    if [[ -n "$_pv_settings_label" ]]; then
      echo "==> P-Vision: installed Devs/Picasso96Settings from $_pv_settings_label"
    else
      echo "==> P-Vision: installed Devs/Picasso96Settings from $(basename "$_pv_settings")"
    fi
    if [[ -n "$_pv_label_patch_count" ]]; then
      echo "==> P-Vision: normalized $_pv_label_patch_count mode labels to pVision"
    fi
  else
    echo "==> WARN: P-Vision: no Picasso96Settings template found; RTG modes may need to be added manually"
  fi

  local _pv_mon="" _pv_mon_info=""
  if [[ -d "$STAGE/p96-monitor-cache" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      [[ -f "$STAGE/p96-monitor-cache/$_try_mon" ]] || continue
      _pv_mon="$STAGE/p96-monitor-cache/$_try_mon"
      [[ -f "$STAGE/p96-monitor-cache/${_try_mon}.info" ]] && _pv_mon_info="$STAGE/p96-monitor-cache/${_try_mon}.info"
      break
    done
  fi
  if [[ -z "$_pv_mon" && -d "$STAGE/p96-extract" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      _pv_mon="$(find "$STAGE/p96-extract" -type f -iname "$_try_mon" -path '*/monitors/*' 2>/dev/null | head -n1)"
      [[ -n "$_pv_mon" ]] || continue
      [[ -f "${_pv_mon}.info" ]] && _pv_mon_info="${_pv_mon}.info"
      break
    done
  fi
  if [[ -z "$_pv_mon" ]]; then
    for _try_mon in Picasso96 Native Generic Altais; do
      fs_dir "$sys_root/Devs/Monitors/$_try_mon" || continue
      local _tmp_mon="$STAGE/pvision-monitor.bin"
      fs_pull_file "$sys_root/Devs/Monitors/$_try_mon" "$_tmp_mon" >/dev/null 2>&1 || continue
      _pv_mon="$_tmp_mon"
      local _tmp_mon_info="$STAGE/pvision-monitor.info"
      if fs_pull_file "$sys_root/Devs/Monitors/${_try_mon}.info" "$_tmp_mon_info" >/dev/null 2>&1; then
        _pv_mon_info="$_tmp_mon_info"
      fi
      break
    done
  fi
  if [[ -z "$_pv_mon" || ! -f "$_pv_mon" ]]; then
    echo "==> WARN: P-Vision: no P96 monitor binary found to create Devs/Monitors/pVision"
    rm -rf "$tmp_pv" "$STAGE/pvision-monitor.bin" "$STAGE/pvision-monitor.info" "$_pv_settings_work" 2>/dev/null || true
    return 1
  fi

  ensure_img_dir "$sys_root/Devs/Monitors"
  fs_copy_file "$_pv_mon" "$sys_root/Devs/Monitors/pVision"
  echo "==> P-Vision: installed Devs/Monitors/pVision"

  if [[ -z "$_pv_mon_info" || ! -f "$_pv_mon_info" ]]; then
    echo "==> WARN: P-Vision: no P96 monitor icon found to create Devs/Monitors/pVision.info"
    rm -rf "$tmp_pv" "$STAGE/pvision-monitor.bin" "$STAGE/pvision-monitor.info" "$_pv_settings_work" 2>/dev/null || true
    return 1
  fi

  local _pv_mon_info_work="$STAGE/pvision-monitor-patched.info"
  cp -f "$_pv_mon_info" "$_pv_mon_info_work"
  if ! modify_tooltype "$_pv_mon_info_work" "BOARDTYPE" "pVision"; then
    echo "==> WARN: P-Vision: failed to set BOARDTYPE=pVision on monitor icon"
    rm -rf "$tmp_pv" "$STAGE/pvision-monitor.bin" "$STAGE/pvision-monitor.info" "$_pv_mon_info_work" "$_pv_settings_work" 2>/dev/null || true
    return 1
  fi
  fs_copy_file "$_pv_mon_info_work" "$sys_root/Devs/Monitors/pVision.info"
  echo "==> P-Vision: installed Devs/Monitors/pVision.info"
  echo "==> P-Vision: set BOARDTYPE=pVision"

  rm -rf "$tmp_pv" "$STAGE/pvision-monitor.bin" "$STAGE/pvision-monitor.info" "$_pv_mon_info_work" "$_pv_settings_work" 2>/dev/null || true
}

# install_vampiresaga_for_classic()
#   Download SAGADriver.lha from Aminet and install the Vampire SAGA RTG driver set:
#   - Libs/Picasso96/vampiregfx.card   (P96 card driver)
#   - Devs/Monitors/vampiregfx         (P96 monitor, from SAGADriver package)
#   - Devs/Picasso96Settings            (board config; SAGADriver may ship
#                                       Picasso96Settings.V2 / .V4 variants)
#   NOTE: Experimental — the Vampire 68080 deviates from standard 68040 behaviour.
#   $1 = sys_root
install_vampiresaga_for_classic() {
  local sys_root="$1"

  echo "==> Vampire SAGA (EXPERIMENTAL): downloading SAGADriver.lha from Aminet"
  local saga_url="https://aminet.net/driver/other/SAGADriver.lha"
  local saga_lha="$STAGE/SAGADriver.lha"
  if ! curl -fL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 \
       -o "$saga_lha" "$saga_url" 2>/dev/null; then
    echo "==> WARN: SAGADriver.lha download failed; Vampire SAGA will not be installed"
    return 0
  fi

  # Extract LHA
  local tmp_saga="$STAGE/saga-extract"
  rm -rf "$tmp_saga" 2>/dev/null || true
  mkdir -p "$tmp_saga"
  "$HST_IMAGER" fs extract "$saga_lha" "$tmp_saga" --recursive --force 2>/dev/null || \
  "$HST_IMAGER" fs extract "$saga_lha" "$tmp_saga" --recursive 2>/dev/null || {
    echo "==> WARN: Failed to extract SAGADriver.lha"
    rm -rf "$tmp_saga" "$saga_lha" 2>/dev/null || true
    return 0
  }

  # --- P96 card driver ---
  local saga_card=""
  saga_card="$(find "$tmp_saga" -type f -iname 'vampiregfx.card' 2>/dev/null | head -n1)"
  if [[ -z "$saga_card" || ! -f "$saga_card" ]]; then
    echo "==> WARN: vampiregfx.card not found in SAGADriver package"
    rm -rf "$tmp_saga" "$saga_lha" 2>/dev/null || true
    return 0
  fi
  ensure_img_dir "$sys_root/Libs/Picasso96"
  fs_copy_file "$saga_card" "$sys_root/Libs/Picasso96/vampiregfx.card"
  echo "==> Vampire SAGA: installed Libs/Picasso96/vampiregfx.card"

  # --- P96 monitor ---
  # SAGADriver ships its own vampiregfx monitor binary; prefer it over the generic one.
  local saga_mon=""
  saga_mon="$(find "$tmp_saga" -type f -iname 'vampiregfx' -not -iname '*.info' 2>/dev/null | head -n1)"
  if [[ -n "$saga_mon" && -f "$saga_mon" ]]; then
    ensure_img_dir "$sys_root/Devs/Monitors"
    fs_copy_file "$saga_mon" "$sys_root/Devs/Monitors/vampiregfx"
    echo "==> Vampire SAGA: installed Devs/Monitors/vampiregfx"
    local saga_mon_info="${saga_mon}.info"
    if [[ -f "$saga_mon_info" ]]; then
      fs_copy_file "$saga_mon_info" "$sys_root/Devs/Monitors/vampiregfx.info"
    fi
  else
    echo "==> WARN: Vampire SAGA: vampiregfx monitor not found in SAGADriver package"
  fi

  # --- P96 board settings ---
  # SAGADriver archives commonly ship board-specific settings as
  # Picasso96Settings.V2 / Picasso96Settings.V4 instead of a generic
  # Picasso96Settings filename.
  local saga_settings=""
  local saga_settings_exact=""
  local saga_settings_v2=""
  local saga_settings_v4=""
  local saga_settings_variant="${VAMPIRE_SAGA_SETTINGS_VARIANT:-}"
  local saga_settings_variant_uc=""
  saga_settings_exact="$(find "$tmp_saga" -type f -iname 'Picasso96Settings' 2>/dev/null | head -n1)"
  saga_settings_v2="$(find "$tmp_saga" -type f -iname 'Picasso96Settings.V2' 2>/dev/null | head -n1)"
  saga_settings_v4="$(find "$tmp_saga" -type f -iname 'Picasso96Settings.V4' 2>/dev/null | head -n1)"
  saga_settings_variant_uc="$(printf '%s' "$saga_settings_variant" | tr '[:lower:]' '[:upper:]')"

  if [[ -n "$saga_settings_exact" && -f "$saga_settings_exact" ]]; then
    saga_settings="$saga_settings_exact"
  else
    case "$saga_settings_variant_uc" in
      V2) saga_settings="$saga_settings_v2" ;;
      V4) saga_settings="$saga_settings_v4" ;;
    esac

    if [[ -z "$saga_settings" || ! -f "$saga_settings" ]]; then
      if [[ "${CLASSIC_MACHINE:-}" == "A2000" && -n "$saga_settings_v2" && -f "$saga_settings_v2" ]]; then
        saga_settings="$saga_settings_v2"
        echo "==> Vampire SAGA: defaulting to Picasso96Settings.V2 for Classic machine A2000"
      elif [[ -n "$saga_settings_v2" && -f "$saga_settings_v2" ]]; then
        saga_settings="$saga_settings_v2"
        if [[ -n "$saga_settings_v4" && -f "$saga_settings_v4" ]]; then
          echo "==> WARN: Vampire SAGA: both Picasso96Settings.V2 and .V4 are present; no explicit board variant selected, defaulting to V2"
        fi
      elif [[ -n "$saga_settings_v4" && -f "$saga_settings_v4" ]]; then
        saga_settings="$saga_settings_v4"
      fi
    fi
  fi

  if [[ -n "$saga_settings" && -f "$saga_settings" ]]; then
    fs_copy_file "$saga_settings" "$sys_root/Devs/Picasso96Settings"
    echo "==> Vampire SAGA: installed Devs/Picasso96Settings (from $(basename "$saga_settings"))"
  else
    echo "==> WARN: Vampire SAGA: no Picasso96Settings file found in SAGADriver package"
  fi

  rm -rf "$tmp_saga" "$saga_lha" 2>/dev/null || true
}

# install_scummvm()
#   Install ScummVM from the pre-downloaded NovaCoder archive (ScummVM_AGA_060.lha).
#   Archive ships a single binary, unpacked theme directories, scummvm.ini, and
#   translations.dat. The ini includes output_rate=11025 which prevents the AHI
#   mixer div-by-zero on systems where no AHI prefs are configured yet.
#   $1 = sys_root
#   $2 = lha_file   (full host path to ScummVM_AGA_060.lha)
#   $3 = cpu_class  (unused — archive has one binary; kept for caller compatibility)
install_scummvm() {
  local sys_root="$1"
  local lha_file="$2"
  local cpu_class="$3"

  local tmp_sv="$STAGE/scummvm-extract"
  rm -rf "$tmp_sv" 2>/dev/null || true
  mkdir -p "$tmp_sv"

  extract_lha_to_host "$lha_file" "$tmp_sv" || {
    echo "==> WARN: ScummVM: failed to extract $(basename "$lha_file")"
    rm -rf "$tmp_sv"
    return 1
  }

  # Descend into single subdirectory (archive root is ScummVM/)
  local sv_root="$tmp_sv"
  local _ndirs
  _ndirs="$(find "$tmp_sv" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')"
  if [[ "$_ndirs" -eq 1 ]]; then
    sv_root="$(find "$tmp_sv" -mindepth 1 -maxdepth 1 -type d | head -1)"
  fi

  # Find the ScummVM binary (named 'ScummVM' in this archive)
  local _bin=""
  _bin="$(find "$sv_root" -maxdepth 1 -type f -iname "ScummVM" 2>/dev/null | head -1)"
  if [[ -z "$_bin" ]]; then
    echo "==> WARN: ScummVM: binary not found in archive"
    find "$sv_root" -maxdepth 2 -not -type d 2>/dev/null | sort | while IFS= read -r _f; do
      echo "==>   $(basename "$_f")"
    done
    rm -rf "$tmp_sv"
    return 1
  fi

  # lhasa does not set +x when extracting Amiga LHA archives to macOS.
  # hst-imager maps host permissions to Amiga protection bits, so the binary
  # must be executable on the host before copying to the image.
  chmod +x "$_bin"
  echo "==> ScummVM: found binary $(basename "$_bin"), marked executable"

  # Remove large files we don't need on the image (source zip, AGA fallback binary, theme builder script)
  rm -f "$sv_root/ScummVM_src.zip" "$sv_root/src.zip" "$sv_root/ScummVM_8bit" "$sv_root/scummtheme.py" 2>/dev/null || true

  # Ensure extrapath is set so ScummVM can find engine data (sky.cpt, lure.dat, etc.)
  local _sv_ini="$sv_root/scummvm.ini"
  if [[ -f "$_sv_ini" ]] && ! grep -q "^extrapath=" "$_sv_ini" 2>/dev/null; then
    awk '/^\[scummvm\]/{print; print "extrapath=SYS:Programs/ScummVM/extras"; next}1' \
      "$_sv_ini" > "${_sv_ini}.tmp" && mv "${_sv_ini}.tmp" "$_sv_ini"
    echo "==> ScummVM: added extrapath=SYS:Programs/ScummVM/extras to scummvm.ini"
  fi

  # Patch the bundled .info icon to set 1 MB stack (ScummVM's engine parsers are recursive)
  if [[ -f "$sv_root/ScummVM.info" ]]; then
    "$AMIGA_TOOLS" patch-info-stack "$sv_root/ScummVM.info" 1048576 2>/dev/null || true
  else
    # Fall back to bundled icon template
    local _tool_icon="$ASSETS_DIR/IconTemplates/ScummVM_tool.info"
    if [[ -f "$_tool_icon" ]]; then
      cp "$_tool_icon" "$sv_root/ScummVM.info"
      "$AMIGA_TOOLS" patch-info-stack "$sv_root/ScummVM.info" 1048576 2>/dev/null || true
    fi
  fi

  # Count theme directories that are present (scummmodern, scummclassic, scummremastered)
  local _theme_count=0
  for _td in scummmodern scummclassic scummremastered residualvm; do
    [[ -d "$sv_root/$_td" ]] && _theme_count=$(( _theme_count + 1 ))
  done
  if [[ "$_theme_count" -eq 0 ]]; then
    echo "==> ERROR: ScummVM: no theme directories found in archive — ScummVM will not start"
    rm -rf "$tmp_sv"
    return 1
  fi

  # Copy the full ScummVM tree to Programs/ScummVM/ on the image.
  # fs_copy with --recursive handles the theme directories correctly.
  ensure_img_dir "$sys_root/Programs/ScummVM"
  fs_copy "$sv_root/" "$sys_root/Programs/ScummVM/" --recursive --makedir || {
    echo "==> WARN: ScummVM: recursive copy failed, falling back to explicit file copy"
    fs_copy_file "$_bin" "$sys_root/Programs/ScummVM/ScummVM"
  }
  echo "==> ScummVM: installed Programs/ScummVM/ (themes=$_theme_count)"

  # Install drawer icon for Programs/ScummVM.info
  local _drawer_icon="$ASSETS_DIR/IconTemplates/ScummVM_drawer.info"
  if [[ -f "$_drawer_icon" ]]; then
    fs_copy_file "$_drawer_icon" "$sys_root/Programs/ScummVM.info"
  fi

  rm -rf "$tmp_sv" 2>/dev/null || true
}

# install_bass()
#   Download the freeware floppy edition of Beneath a Steel Sky from downloads.scummvm.org
#   (released as freeware by Revolution Software in 2003) and install the two game data
#   files to SYS:Games/BeneathASteelSky/ for use with ScummVM.
#   ZIP contains: sky.dnr, sky.dsk  (sky.cpt is bundled in ScummVM/extras/)
#   $1 = sys_root
install_bass() {
  local sys_root="$1"

  local bass_url="https://downloads.scummvm.org/frs/extras/Beneath%20a%20Steel%20Sky/BASS-Floppy-1.3.zip"
  local bass_zip="$STAGE/BASS-Floppy-1.3.zip"
  echo "==> BASS: downloading Beneath a Steel Sky floppy freeware"
  if ! curl -fL --connect-timeout 15 --max-time 120 --retry 2 --retry-delay 2 \
       -o "$bass_zip" "$bass_url" 2>/dev/null; then
    echo "==> WARN: BASS: download failed from $bass_url"
    return 0
  fi

  local tmp_bass="$STAGE/bass-extract"
  rm -rf "$tmp_bass" 2>/dev/null || true
  mkdir -p "$tmp_bass"

  if ! unzip -q -o "$bass_zip" -d "$tmp_bass" 2>/dev/null; then
    echo "==> WARN: BASS: extraction failed"
    rm -rf "$tmp_bass" "$bass_zip" 2>/dev/null || true
    return 0
  fi

  ensure_img_dir "$sys_root/Games/BeneathASteelSky"

  local _installed=0
  for _fname in sky.dnr sky.dsk; do
    local _f=""
    _f="$(find "$tmp_bass" -iname "$_fname" -type f 2>/dev/null | head -1)"
    if [[ -n "$_f" && -f "$_f" ]]; then
      fs_copy_file "$_f" "$sys_root/Games/BeneathASteelSky/$_fname"
      echo "==> BASS: installed Games/BeneathASteelSky/$_fname ($(du -k "$_f" | cut -f1) KB)"
      _installed=$(( _installed + 1 ))
    fi
  done

  if [[ "$_installed" -eq 0 ]]; then
    echo "==> WARN: BASS: no game data files found in archive"
  else
    echo "==> BASS: ${_installed} file(s) installed to Games/BeneathASteelSky"
    _register_scummvm_game "sky" "SYS:Games/BeneathASteelSky"
  fi

  rm -rf "$tmp_bass" "$bass_zip" 2>/dev/null || true
}

# _install_scummvm_game_zip()
#   Generic helper: download a freeware ScummVM game ZIP and copy listed data files
#   to a target directory on the image.
#   $1 = download URL
#   $2 = local ZIP filename (basename, placed in $STAGE)
#   $3 = target directory on image (e.g. "$SYS_ROOT/Games/FlightOfTheAmazonQueen")
#   $4+ = data filenames to search for (case-insensitive) and install
_install_scummvm_game_zip() {
  local url="$1"
  local zip_name="$2"
  local target_dir="$3"
  shift 3
  local data_files=("$@")

  local zip_path="$STAGE/$zip_name"
  echo "==> ScummVM game: downloading $zip_name"
  if ! curl -fL --connect-timeout 15 --max-time 120 --retry 2 --retry-delay 2 \
       -o "$zip_path" "$url" 2>/dev/null; then
    echo "==> WARN: ScummVM game: download failed from $url"
    return 0
  fi

  local tmp_dir="$STAGE/game-extract-$$"
  rm -rf "$tmp_dir" 2>/dev/null || true
  mkdir -p "$tmp_dir"

  if ! unzip -q -o "$zip_path" -d "$tmp_dir" 2>/dev/null; then
    echo "==> WARN: ScummVM game: extraction failed for $zip_name"
    rm -rf "$tmp_dir" "$zip_path" 2>/dev/null || true
    return 0
  fi

  ensure_img_dir "$target_dir"

  local _installed=0
  local _fname _f
  for _fname in "${data_files[@]}"; do
    _f="$(find "$tmp_dir" -iname "$_fname" -type f 2>/dev/null | head -1)"
    if [[ -n "$_f" && -f "$_f" ]]; then
      fs_copy_file "$_f" "$target_dir/$_fname"
      echo "==> ScummVM game: installed $(basename "$target_dir")/$_fname ($(du -k "$_f" | cut -f1) KB)"
      _installed=$(( _installed + 1 ))
    fi
  done

  if [[ "$_installed" -eq 0 ]]; then
    echo "==> WARN: ScummVM game: no data files found in $zip_name"
  else
    echo "==> ScummVM game: ${_installed} file(s) installed to $(basename "$target_dir")"
  fi

  rm -rf "$tmp_dir" "$zip_path" 2>/dev/null || true
}

# _register_scummvm_game()
#   Stage a [game_id] / path= entry for later application to scummvm.ini.
#   Writes to $STAGE/scummvm_game_entries.ini on the host; no image I/O here.
#   Call apply_scummvm_game_entries() once after all game installs to flush.
#   $1 = game_id    (ScummVM engine/game ID, e.g. "sky", "queen", "lure")
#   $2 = amiga_path (Amiga volume path, e.g. "SYS:Games/BeneathASteelSky")
_register_scummvm_game() {
  local game_id="$1"
  local amiga_path="$2"
  local entries_file="$STAGE/scummvm_game_entries.ini"
  printf '[%s]\npath=%s\n\n' "$game_id" "$amiga_path" >> "$entries_file"
  echo "==> ScummVM: queued [$game_id] path=$amiga_path"
}

# apply_scummvm_game_entries()
#   Single-pass: pull scummvm.ini from the image, append any queued game
#   entries (skipping duplicates), push back. No-op if nothing was queued or
#   ScummVM is not installed.
#   $1 = sys_root
apply_scummvm_game_entries() {
  local sys_root="$1"
  local entries_file="$STAGE/scummvm_game_entries.ini"

  [[ -f "$entries_file" ]] || return 0

  local ini_on_image="$sys_root/Programs/ScummVM/scummvm.ini"
  local tmp_ini="$STAGE/scummvm.ini.patch"

  if ! fs_pull_file "$ini_on_image" "$tmp_ini" 2>/dev/null; then
    echo "==> WARN: ScummVM: scummvm.ini not found on image — game entries not applied"
    rm -f "$entries_file"
    return 0
  fi

  local _added=0
  local _game_id _amiga_path
  while IFS= read -r _line; do
    if [[ "$_line" =~ ^\[([^]]+)\]$ ]]; then
      _game_id="${BASH_REMATCH[1]}"
    elif [[ "$_line" =~ ^path=(.+)$ ]]; then
      _amiga_path="${BASH_REMATCH[1]}"
      if ! grep -q "^\[$_game_id\]" "$tmp_ini" 2>/dev/null; then
        printf '\n[%s]\npath=%s\n' "$_game_id" "$_amiga_path" >> "$tmp_ini"
        echo "==> ScummVM: registered [$_game_id] path=$_amiga_path in scummvm.ini"
        _added=$(( _added + 1 ))
      else
        echo "==> ScummVM: [$_game_id] already in scummvm.ini, skipping"
      fi
    fi
  done < "$entries_file"

  if [[ "$_added" -gt 0 ]]; then
    fs_copy_file "$tmp_ini" "$ini_on_image"
  fi

  rm -f "$tmp_ini" "$entries_file"
}

# install_fotaq()
#   Download the freeware floppy edition of Flight of the Amazon Queen from
#   downloads.scummvm.org and install to SYS:Games/FlightOfTheAmazonQueen/.
#   ZIP contains: queen.1 only  (queen.tbl is bundled in ScummVM/extras/)
#   $1 = sys_root
install_fotaq() {
  local sys_root="$1"
  _install_scummvm_game_zip \
    "https://downloads.scummvm.org/frs/extras/Flight%20of%20the%20Amazon%20Queen/FOTAQ_Floppy.zip" \
    "FOTAQ_Floppy.zip" \
    "$sys_root/Games/FlightOfTheAmazonQueen" \
    "queen.1"
  _register_scummvm_game "queen" "SYS:Games/FlightOfTheAmazonQueen"
}

# install_lure()
#   Download the freeware edition of Lure of the Temptress from
#   downloads.scummvm.org and install to SYS:Games/LureOfTheTemptress/.
#   ZIP contains: Disk1-4.vga + disk1-4.ega  (lure.dat is bundled in ScummVM/extras/)
#   We install the VGA versions (better colour on Amiga RTG).
#   $1 = sys_root
install_lure() {
  local sys_root="$1"
  _install_scummvm_game_zip \
    "https://downloads.scummvm.org/frs/extras/Lure%20of%20the%20Temptress/lure-1.1.zip" \
    "lure-1.1.zip" \
    "$sys_root/Games/LureOfTheTemptress" \
    "disk1.vga" "disk2.vga" "disk3.vga" "disk4.vga"
  _register_scummvm_game "lure" "SYS:Games/LureOfTheTemptress"
}

# download_and_install_mecho()
#   Download mecho from Aminet and install to SYS:C/.
#   mecho (Michael Glicksman) is an enhanced echo command supporting escape sequences
#   (e.g. \r for carriage return). Required by S:ProgressBar for the network progress
#   animation used by PiStorm Online/Offline tools.
#   $1 = sys_root
download_and_install_mecho() {
  local sys_root="$1"

  # Skip if mecho already exists on image
  if fs_dir "$sys_root/C/mecho"; then
    echo "==> mecho: already present in C/"
    return 0
  fi

  echo "==> mecho: downloading from Aminet (needed by S:ProgressBar)"
  local mecho_url="https://aminet.net/util/batch/mecho.lha"
  local mecho_lha="$STAGE/mecho.lha"

  if ! curl -fL --connect-timeout 10 --max-time 30 --retry 2 --retry-delay 2 \
       -o "$mecho_lha" "$mecho_url" 2>/dev/null; then
    echo "==> WARN: mecho download failed; PiStorm progress bar will not work"
    return 0
  fi

  local tmp_me="$STAGE/mecho-extract"
  rm -rf "$tmp_me" 2>/dev/null || true
  mkdir -p "$tmp_me"

  "$HST_IMAGER" fs extract "$mecho_lha" "$tmp_me" --recursive --force 2>/dev/null || \
  "$HST_IMAGER" fs extract "$mecho_lha" "$tmp_me" --recursive 2>/dev/null || {
    echo "==> WARN: Failed to extract mecho LHA"
    rm -rf "$tmp_me" 2>/dev/null || true
    return 0
  }

  local mecho_bin=""
  mecho_bin="$(find "$tmp_me" -type f -name "mecho" 2>/dev/null | head -n1)"
  if [[ -z "$mecho_bin" ]]; then
    # Some archives capitalise the name
    mecho_bin="$(find "$tmp_me" -type f -iname "mecho" 2>/dev/null | head -n1)"
  fi
  if [[ -n "$mecho_bin" && -f "$mecho_bin" ]]; then
    local tmp_mecho_bin="$STAGE/mecho.$$.$RANDOM"
    cp -f "$mecho_bin" "$tmp_mecho_bin"
    write_uaem_with_perms "----rwed" "$tmp_mecho_bin"
    ensure_img_dir "$sys_root/C"
    fs_copy_file "$tmp_mecho_bin" "$sys_root/C/mecho" --uaemetadata UaeMetafile
    echo "==> mecho installed to C/mecho"
  else
    echo "==> WARN: mecho binary not found in extracted archive"
  fi

  rm -rf "$tmp_me" "$mecho_lha" 2>/dev/null || true
}

# download_and_install_wirelessmanager()
#   Download prism2v2.lha from Aminet and install WirelessManager + wpa_passphrase
#   to SYS:C/. WirelessManager is required by the PiStorm Online script for WiFi:
#   it associates with the WiFi AP using ENVARC:Sys/Wireless.prefs before
#   AddNetInterface can bring the wifipi interface up.
#   $1 = sys_root

download_and_install_screennotify() {
  local sys_root="$1"

  if fs_dir "$sys_root/Libs/screennotify.library"; then
    echo "==> screennotify.library: already present in LIBS:"
    return 0
  fi

  echo "==> screennotify.library: downloading from Aminet (needed by WBDock2)"
  local sn_url="https://aminet.net/util/libs/ScreenNotify10.lha"
  local sn_lha="$STAGE/ScreenNotify10.lha"

  if ! curl -fL --connect-timeout 10 --max-time 30 --retry 2 --retry-delay 2 \
       -o "$sn_lha" "$sn_url" 2>/dev/null; then
    echo "==> WARN: screennotify.library download failed"
    return 0
  fi

  local tmp_sn="$STAGE/screennotify-extract"
  rm -rf "$tmp_sn" 2>/dev/null || true
  mkdir -p "$tmp_sn"

  local _lha_bin=""
  _lha_bin="$(find_lha)" || true
  local _sn_ok=0
  if [[ -n "$_lha_bin" ]]; then
    (cd "$tmp_sn" && "$_lha_bin" x "$sn_lha") && _sn_ok=1
  fi
  if (( _sn_ok == 0 )); then
    "$HST_IMAGER" fs extract "$sn_lha" "$tmp_sn" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$sn_lha" "$tmp_sn" --recursive 2>/dev/null || {
      echo "==> WARN: Failed to extract ScreenNotify LHA"
      rm -rf "$tmp_sn" 2>/dev/null || true
      return 0
    }
  fi

  local sn_lib=""
  sn_lib="$(find "$tmp_sn" -type f -iname 'screennotify.library' 2>/dev/null | head -n1)"
  if [[ -n "$sn_lib" && -f "$sn_lib" ]]; then
    ensure_img_dir "$sys_root/Libs"
    fs_copy_file "$sn_lib" "$sys_root/Libs/screennotify.library"
    echo "==> screennotify.library installed to LIBS:"
  else
    echo "==> WARN: screennotify.library not found in extracted archive"
  fi

  rm -rf "$tmp_sn" "$sn_lha" 2>/dev/null || true
}

# download_and_install_codesets_library()
#   Download codesets.library 6.x from GitHub and install to LIBS:.
#   Required by LumiFTP and LumiWeather for filename encoding (UTF-8 / local charset).
#   $1 = sys_root
download_and_install_codesets_library() {
  local sys_root="$1"

  if fs_dir "$sys_root/Libs/codesets.library"; then
    echo "==> codesets.library: already present in LIBS:"
    return 0
  fi

  echo "==> codesets.library: downloading from GitHub (needed by LumiFTP/LumiWeather)"
  local cs_url="https://github.com/jens-maus/libcodesets/releases/download/6.22/codesets-6.22.lha"
  local cs_lha="$STAGE/codesets-6.22.lha"

  if ! curl -fL --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 2 \
       -o "$cs_lha" "$cs_url" 2>/dev/null; then
    echo "==> WARN: codesets.library download failed; LumiFTP/LumiWeather may report missing library"
    return 0
  fi

  local tmp_cs="$STAGE/codesets-extract"
  rm -rf "$tmp_cs" 2>/dev/null || true
  mkdir -p "$tmp_cs"

  if ! extract_lha_to_host "$cs_lha" "$tmp_cs"; then
    echo "==> WARN: Failed to extract codesets LHA"
    rm -rf "$tmp_cs" "$cs_lha" 2>/dev/null || true
    return 0
  fi

  # Archive contains builds for multiple platforms (AmigaOS3=68k, AROS-i386, AmigaOS4, MorphOS…).
  # Prefer AmigaOS3 (68k); fall back to any non-AROS/non-PPC build; last resort: first match.
  local cs_lib=""
  cs_lib="$(find "$tmp_cs" -type f -iname 'codesets.library' 2>/dev/null \
            | grep -i 'AmigaOS3' | head -n1 || true)"
  [[ -z "$cs_lib" ]] && \
    cs_lib="$(find "$tmp_cs" -type f -iname 'codesets.library' 2>/dev/null \
              | grep -iv 'AROS\|MorphOS\|AmigaOS4\|powerpc\|ppc' | head -n1 || true)"
  [[ -z "$cs_lib" ]] && \
    cs_lib="$(find "$tmp_cs" -type f -iname 'codesets.library' 2>/dev/null | head -n1 || true)"
  if [[ -n "$cs_lib" && -f "$cs_lib" ]]; then
    chmod +x "$cs_lib" 2>/dev/null || true
    ensure_img_dir "$sys_root/Libs"
    fs_copy_file "$cs_lib" "$sys_root/Libs/codesets.library"
    echo "==> codesets.library installed to LIBS:"
  else
    echo "==> WARN: codesets.library not found in extracted archive"
  fi

  rm -rf "$tmp_cs" "$cs_lha" 2>/dev/null || true
}

# download_and_install_iconlib()
#   Download IconLib 46.4 from Aminet and install icon.library (68020+) to LIBS:.
#   Enhanced icon.library with support for GlowIcons, PNG, TrueColor formats.
#   $1 = sys_root
download_and_install_iconlib() {
  local sys_root="$1"

  echo "==> icon.library 46.4: downloading from Aminet"
  local il_url="https://aminet.net/util/libs/IconLib_46.4.lha"
  local il_lha="$STAGE/IconLib_46.4.lha"

  if ! curl -fL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 \
       -o "$il_lha" "$il_url" 2>/dev/null; then
    echo "==> WARN: icon.library download failed"
    return 0
  fi

  local tmp_il="$STAGE/iconlib-extract"
  rm -rf "$tmp_il" 2>/dev/null || true
  mkdir -p "$tmp_il"

  local _lha_bin=""
  _lha_bin="$(find_lha)" || true
  local _il_ok=0
  if [[ -n "$_lha_bin" ]]; then
    (cd "$tmp_il" && "$_lha_bin" x "$il_lha") && _il_ok=1
  fi
  if (( _il_ok == 0 )); then
    "$HST_IMAGER" fs extract "$il_lha" "$tmp_il" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$il_lha" "$tmp_il" --recursive 2>/dev/null || {
      echo "==> WARN: Failed to extract IconLib LHA"
      rm -rf "$tmp_il" 2>/dev/null || true
      return 0
    }
  fi

  # Use the 68020+ version (Emu68 emulates 68040)
  local il_lib=""
  il_lib="$(find "$tmp_il" -path '*/Libs/icon.library' ! -path '*/68000/*' ! -path '*/Bonus/*' 2>/dev/null | head -n1)"
  if [[ -n "$il_lib" && -f "$il_lib" ]]; then
    ensure_img_dir "$sys_root/Libs"
    fs_copy_file "$il_lib" "$sys_root/Libs/icon.library"
    echo "==> icon.library 46.4 (68020+) installed to LIBS:"
  else
    echo "==> WARN: icon.library not found in extracted archive"
  fi

  rm -rf "$tmp_il" "$il_lha" 2>/dev/null || true
}

# download_and_install_whdload_wrapper()
#   Download the latest WHDLoadWrapper from grandis.nu and install into the
#   image.  This replaces the original WHDLoad binary (already renamed to
#   WHDLoad.ori by the CSV installer) with the wrapper that auto-selects the
#   correct kickstart ROM per game.
#   $1 = sys_root  (image path to SYS: volume)
download_and_install_whdload_wrapper() {
  local sys_root="$1"
  echo "==> WHDLoadWrapper: downloading and installing"

  # --- 1) Discover the latest version URL from grandis.nu search ----------
  local search_url="http://ftp2.grandis.nu/turransearch/search.php?_search_=1&search=WHDLoadWrapper&category_id=&exclude=&limit=10&httplinks=on&username=ftp%2Cany&filesonly=on"
  local search_html="$STAGE/whdwrapper-search.html"
  local wrapper_url=""

  if curl -fsSL --connect-timeout 10 --max-time 30 -o "$search_html" "$search_url" 2>/dev/null; then
    # Extract WHDLoadWrapper LHA URLs, pick the highest version number
    wrapper_url="$(grep -oE 'http[s]?://[^"'"'"' <>]+WHDLoadWrapper[0-9]+\.lha' "$search_html" 2>/dev/null \
      | sort -t 'r' -k2 -V | tail -n1 || true)"
  fi
  rm -f "$search_html" 2>/dev/null || true

  # Fallback to last known version if search failed
  if [[ -z "$wrapper_url" ]]; then
    wrapper_url="http://ftp2.grandis.nu/turran/FTP/Misc/WHDUpdate/datafile/WHDLoadWrapper017.lha"
    echo "  -> Using fallback URL: $wrapper_url"
  else
    echo "  -> Found latest: $wrapper_url"
  fi

  # --- 2) Download the LHA ------------------------------------------------
  local wrapper_lha="$STAGE/WHDLoadWrapper.lha"
  if ! curl -fL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 \
       -o "$wrapper_lha" "$wrapper_url" 2>/dev/null; then
    echo "==> WARN: WHDLoadWrapper download failed; WHDLoad.ori will remain without wrapper"
    return 0
  fi

  # --- 3) Extract ----------------------------------------------------------
  local tmp_ww="$STAGE/whdwrapper-extract"
  rm -rf "$tmp_ww" 2>/dev/null || true
  mkdir -p "$tmp_ww"

  "$HST_IMAGER" fs extract "$wrapper_lha" "$tmp_ww" --recursive --force 2>/dev/null || \
  "$HST_IMAGER" fs extract "$wrapper_lha" "$tmp_ww" --recursive 2>/dev/null || {
    echo "==> WARN: Failed to extract WHDLoadWrapper LHA"
    rm -rf "$tmp_ww" 2>/dev/null || true
    return 0
  }

  # --- 4) Install C/ binaries to SYS:C/ -----------------------------------
  ensure_img_dir "$sys_root/C"
  local c_dir=""
  c_dir="$(find "$tmp_ww" -maxdepth 2 -type d -name "C" | head -n1 || true)"
  if [[ -n "$c_dir" && -d "$c_dir" ]]; then
    local f
    for f in "$c_dir"/*; do
      [[ -f "$f" ]] || continue
      local fname
      fname="$(basename "$f")"
      fs_copy_file "$f" "$sys_root/C/$fname"
      echo "  -> C/$fname -> SYS:C/$fname"
    done

    # Also install WHDLoadWrapper as SYS:C/WHDLoad (the replacement binary)
    if [[ -f "$c_dir/WHDLoadWrapper" ]]; then
      fs_copy_file "$c_dir/WHDLoadWrapper" "$sys_root/C/WHDLoad"
      echo "  -> C/WHDLoadWrapper -> SYS:C/WHDLoad (wrapper replaces original)"
    fi
  fi

  # --- 5) Install S/WHDLoad-Wrapper/ tree (scripts, Updater/, etc.) -------
  ensure_img_dir "$sys_root/S/WHDLoad-Wrapper"
  local s_dir=""
  s_dir="$(find "$tmp_ww" -maxdepth 3 -type d -name "WHDLoad-Wrapper" -path "*/S/*" | head -n1 || true)"
  if [[ -n "$s_dir" && -d "$s_dir" ]]; then
    # Install files directly in WHDLoad-Wrapper/
    local f
    for f in "$s_dir"/*; do
      [[ -f "$f" ]] || continue
      local fname
      fname="$(basename "$f")"
      # Rename StartupScript_ScreenText -> StartupScript (Windows parity)
      if [[ "$fname" == "StartupScript_ScreenText" ]]; then
        fs_copy_file "$f" "$sys_root/S/WHDLoad-Wrapper/StartupScript"
        echo "  -> S/WHDLoad-Wrapper/StartupScript_ScreenText -> StartupScript"
      else
        fs_copy_file "$f" "$sys_root/S/WHDLoad-Wrapper/$fname"
        echo "  -> S/WHDLoad-Wrapper/$fname"
      fi
    done
    # Recursively install subdirectories (e.g. Updater/, containing WHDUpdate)
    local subdir
    for subdir in "$s_dir"/*/; do
      [[ -d "$subdir" ]] || continue
      local subname
      subname="$(basename "$subdir")"
      echo "  -> S/WHDLoad-Wrapper/$subname/ (recursive)"
      fs_copy_tree_filtered "$subdir" "$sys_root/S/WHDLoad-Wrapper/$subname"
    done
  fi

  # --- 6) Install DB/ and DB-General/ database files ----------------------
  local db_dir
  for db_dir in $(find "$tmp_ww" -maxdepth 2 -type d \( -name "DB" -o -name "DB-General" \) 2>/dev/null); do
    local f
    for f in "$db_dir"/*; do
      [[ -f "$f" ]] || continue
      local fname
      fname="$(basename "$f")"
      fs_copy_file "$f" "$sys_root/S/WHDLoad-Wrapper/$fname"
      echo "  -> $(basename "$db_dir")/$fname -> S/WHDLoad-Wrapper/$fname"
    done
  done

  rm -rf "$tmp_ww" 2>/dev/null || true
  echo "==> WHDLoadWrapper: installation complete"
}

# install_whdload_kickstarts()
#   Scan a user-supplied folder for Kickstart ROM files, identify them by MD5
#   hash, and copy recognized ROMs to DEVS:Kickstarts/ with the canonical
#   WHDLoad filenames (e.g. kick40068.A1200).
#   $1 = sys_root               (image path to SYS: volume)
#   $2 = kickstarts_source_dir  (host path to folder with ROM files)
install_whdload_kickstarts() {
  local sys_root="$1"
  local src_dir="$2"
  echo "==> WHDLoad Kickstarts: scanning $src_dir for recognized ROMs"

  # Locate the hash file (bundled in SYS:S/ or from Assets)
  local hash_file=""
  hash_file="$(find "$ASSETS_DIR" -name "KickStartRomHashes" -o -name "KickstartRomHashes" 2>/dev/null | head -n1 || true)"
  if [[ -z "$hash_file" || ! -f "$hash_file" ]]; then
    echo "==> WARN: KickStartRomHashes not found in assets; skipping kickstart ROM installation"
    return 0
  fi

  ensure_img_dir "$sys_root/Devs/Kickstarts"

  local installed=0
  local f
  for f in "$src_dir"/*; do
    [[ -f "$f" ]] || continue

    local fsize
    fsize="$(stat -f '%z' "$f" 2>/dev/null || stat -c '%s' "$f" 2>/dev/null || echo 0)"
    # Accept 256KB (262144) and 512KB (524288) ROMs — both are valid kickstart sizes
    if [[ "$fsize" -ne 524288 && "$fsize" -ne 262144 ]]; then
      continue
    fi

    local md5
    md5="$(md5 -q "$f" 2>/dev/null || md5sum "$f" 2>/dev/null | awk '{print $1}' || true)"
    [[ -n "$md5" ]] || continue

    # Case-insensitive hash lookup in the hash file (format: Hash;WHDLoadName)
    local match_line
    match_line="$(grep -i "^${md5};" "$hash_file" 2>/dev/null | head -n1 || true)"
    if [[ -n "$match_line" ]]; then
      local whdname
      whdname="$(echo "$match_line" | cut -d';' -f2 | tr -d '\r')"
      if [[ -n "$whdname" ]]; then
        fs_copy_file "$f" "$sys_root/Devs/Kickstarts/$whdname"
        echo "  -> $(basename "$f") => DEVS:Kickstarts/$whdname (MD5: $md5)"
        installed=$((installed + 1))

        # Copy companion .RTB and .PAT files — WHDLoad needs these for
        # ROM transfer backup data and runtime patching.
        local src_base
        src_base="$(basename "$f")"
        for ext in RTB PAT; do
          # By source filename (e.g., kick34005.A500.RTB alongside kick34005.A500)
          if [[ -f "$src_dir/${src_base}.${ext}" ]]; then
            fs_copy_file "$src_dir/${src_base}.${ext}" "$sys_root/Devs/Kickstarts/${whdname}.${ext}"
            echo "  -> ${src_base}.${ext} => DEVS:Kickstarts/${whdname}.${ext}"
          # By canonical WHDLoad name (if source file was renamed)
          elif [[ -f "$src_dir/${whdname}.${ext}" ]]; then
            fs_copy_file "$src_dir/${whdname}.${ext}" "$sys_root/Devs/Kickstarts/${whdname}.${ext}"
            echo "  -> ${whdname}.${ext} => DEVS:Kickstarts/${whdname}.${ext}"
          fi
        done
      fi
    fi
  done

  # Also install the hash file itself to SYS:S/ for runtime reference
  fs_copy_file "$hash_file" "$sys_root/S/KickStartRomHashes"

  if [[ "$installed" -gt 0 ]]; then
    echo "==> WHDLoad Kickstarts: installed $installed ROM(s)"
  else
    echo "==> WHDLoad Kickstarts: no recognized ROMs found in $src_dir"
  fi
}

# --- CSV and path helpers (Windows parity data model) ------------------------


# ===========================================================================
# Icon positioning & ToolTypes from CSVs
# ===========================================================================
trim_spaces() {
  local s="${1:-}"
  s="${s#"${s%%[![:space:]]*}"}"
  s="${s%"${s##*[![:space:]]}"}"
  printf "%s" "$s"
}

normalize_rel_path() {
  local p="${1:-}"
  p="${p//\\//}"
  p="$(trim_spaces "$p")"
  p="${p#./}"
  while [[ "$p" == /* ]]; do
    p="${p#/}"
  done
  while [[ "$p" == *"//"* ]]; do
    p="${p//\/\//\/}"
  done
  # Remove trailing slashes
  while [[ "$p" == */ ]]; do
    p="${p%/}"
  done
  printf "%s" "$p"
}

img_dir_has_entry() {
  # Check if an exact entry name exists in an image directory listing.
  # $1 = image directory path
  # $2 = exact entry name
  local dir_path="$1"
  local entry_name="$2"
  # Native engine: use fs_dir on the specific entry path (FFS is case-insensitive so
  # this also handles case variants, but we match what FFS finds).
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _disk_parse_path "$dir_path"
    if [[ -n "$_dp_part" ]]; then
      local _entry_apath="${_dp_path%/}/$entry_name"
      _entry_apath="${_entry_apath#/}"
      "$AMIGA_TOOLS" disk fs dir "$DISK_ENGINE_IMG" "$_dp_part" "$_entry_apath" >/dev/null 2>&1
      return $?
    fi
  fi
  local listing
  listing="$("$HST_IMAGER" fs dir "$dir_path" 2>/dev/null || true)"
  [[ -n "$listing" ]] || return 1

  printf "%s\n" "$listing" | awk -F'|' -v want="$entry_name" '
    function trim(s){ sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
    /^[[:space:]]*Name[[:space:]]*\|/ { next }
    /^[[:space:]]*[-]+[[:space:]]*\|/ { next }
    /^[[:space:]]*Path:/ { next }
    /^[[:space:]]*Entries:/ { next }
    /^[[:space:]]*$/ { next }
    /\[CMD\]/ { next }
    /\[INF\]/ { next }
    {
      name=trim($1)
      if (name == want) { found=1; exit 0 }
    }
    END { if (!found) exit 1 }
  '
}

img_dir_has_entry_ci() {
  # Case-insensitive entry check for Amiga drawers/files.
  # Use this for command presence checks where case is irrelevant (e.g. C:lha).
  local dir_path="$1"
  local entry_name="$2"
  # Native engine: FFS lookup is inherently case-insensitive — same as img_dir_has_entry.
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _disk_parse_path "$dir_path"
    if [[ -n "$_dp_part" ]]; then
      local _entry_apath="${_dp_path%/}/$entry_name"
      _entry_apath="${_entry_apath#/}"
      "$AMIGA_TOOLS" disk fs dir "$DISK_ENGINE_IMG" "$_dp_part" "$_entry_apath" >/dev/null 2>&1
      return $?
    fi
  fi
  local listing
  listing="$("$HST_IMAGER" fs dir "$dir_path" 2>/dev/null || true)"
  [[ -n "$listing" ]] || return 1

  printf "%s\n" "$listing" | awk -F'|' -v want="$entry_name" '
    function trim(s){ sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
    BEGIN { want_l=tolower(want) }
    /^[[:space:]]*Name[[:space:]]*\|/ { next }
    /^[[:space:]]*[-]+[[:space:]]*\|/ { next }
    /^[[:space:]]*Path:/ { next }
    /^[[:space:]]*Entries:/ { next }
    /^[[:space:]]*$/ { next }
    /\[CMD\]/ { next }
    /\[INF\]/ { next }
    {
      name=tolower(trim($1))
      if (name == want_l) { found=1; exit 0 }
    }
    END { if (!found) exit 1 }
  '
}

find_input_csv() {
  local filename="${1:-}"
  local base="${filename%.*}"
  local ext="${filename##*.}"
  local lower="${base}.$(echo "$ext" | tr '[:upper:]' '[:lower:]')"
  local cand

  # If --csv-dir was passed, check it first (single source of truth).
  if [[ -n "$CSV_DIR" ]]; then
    for cand in "$CSV_DIR/$filename" "$CSV_DIR/$lower"; do
      [[ -f "$cand" ]] && { echo "$cand"; return 0; }
    done
  fi

  for cand in \
    "$ASSETS_DIR/../InputFiles/$filename" \
    "$ASSETS_DIR/../InputFiles/$lower" \
    "$ASSETS_DIR/InputFiles/$filename" \
    "$ASSETS_DIR/InputFiles/$lower" \
    "$ASSETS_DIR/AmigaFiles/InputFiles/$filename" \
    "$ASSETS_DIR/AmigaFiles/InputFiles/$lower" \
    "$ASSETS_DIR/$filename" \
    "$ASSETS_DIR/$lower"
  do
    [[ -f "$cand" ]] && { echo "$cand"; return 0; }
  done
  return 1
}

icon_type_to_num() {
  local raw
  raw="$(trim_spaces "${1:-}")"
  [[ -z "$raw" ]] && { echo ""; return 0; }
  local low
  low="$(echo "$raw" | tr '[:upper:]' '[:lower:]')"
  case "$low" in
    1|disk) echo "1" ;;
    2|drawer) echo "2" ;;
    3|tool) echo "3" ;;
    4|project) echo "4" ;;
    5|garbage) echo "5" ;;
    6|device) echo "6" ;;
    7|kick) echo "7" ;;
    8|appicon) echo "8" ;;
    *) echo "" ;;
  esac
}

resolve_drive_root() {
  local drive
  drive="$(trim_spaces "${1:-}")"
  [[ -z "$drive" ]] && return 1

  local d_upper
  d_upper="$(echo "$drive" | tr '[:lower:]' '[:upper:]')"

  case "$d_upper" in
    SYS|SYSTEM|DH0|SDH0|WORKBENCH)
      echo "$SYS_ROOT"
      return 0
      ;;
    SD0|EMU68BOOT|BOOT|FAT)
      echo "$FAT_ROOT"
      return 0
      ;;
  esac

  local _pi part_name part_label
  for _pi in $(seq 0 $((NUM_PARTS - 1))); do
    part_name="$(echo "${PART_NAMES[$_pi]:-}" | tr '[:lower:]' '[:upper:]')"
    part_label="$(echo "${PART_LABELS[$_pi]:-}" | tr '[:lower:]' '[:upper:]')"
    if [[ "$d_upper" == "$part_name" || "$d_upper" == "$part_label" || "$d_upper" == "DH${_pi}" ]]; then
      if [[ "$_pi" -eq 0 ]]; then
        echo "$SYS_ROOT"
      else
        echo "$IMG/mbr/2/rdb/${PART_PNS[$_pi]}"
      fi
      return 0
    fi
  done

  return 1
}

hst_icon_update_in_place() {
  local info_path="$1"
  local icon_type="${2:-}"
  local current_x="${3:-}"
  local current_y="${4:-}"
  local drawer_x="${5:-}"
  local drawer_y="${6:-}"
  local drawer_width="${7:-}"
  local drawer_height="${8:-}"

  [[ -f "$info_path" ]] || return 1

  local cmd=( "$AMIGA_TOOLS" icon-update "$info_path" )
  [[ -n "$icon_type" ]] && cmd+=( --type "$icon_type" )

  local x_upper y_upper
  x_upper="$(echo "$current_x" | tr '[:lower:]' '[:upper:]')"
  y_upper="$(echo "$current_y" | tr '[:lower:]' '[:upper:]')"

  [[ -n "$current_x" && "$x_upper" != "FREEX" ]] && cmd+=( --current-x "$current_x" )
  [[ -n "$current_y" && "$y_upper" != "FREEY" ]] && cmd+=( --current-y "$current_y" )
  [[ -n "$drawer_x" ]] && cmd+=( --drawer-x "$drawer_x" )
  [[ -n "$drawer_y" ]] && cmd+=( --drawer-y "$drawer_y" )
  [[ -n "$drawer_width" ]] && cmd+=( --drawer-width "$drawer_width" )
  [[ -n "$drawer_height" ]] && cmd+=( --drawer-height "$drawer_height" )

  "${cmd[@]}" >/dev/null 2>&1
}

apply_icon_update_to_image_file() {
  local img_info="$1"
  local icon_type="${2:-}"
  local current_x="${3:-}"
  local current_y="${4:-}"
  local drawer_x="${5:-}"
  local drawer_y="${6:-}"
  local drawer_width="${7:-}"
  local drawer_height="${8:-}"

  local tmp_info="$STAGE/hst-icon-update-$(basename "$img_info").$$.$RANDOM.info"
  if ! fs_pull_file "$img_info" "$tmp_info"; then
    return 1
  fi

  if ! hst_icon_update_in_place "$tmp_info" "$icon_type" "$current_x" "$current_y" "$drawer_x" "$drawer_y" "$drawer_width" "$drawer_height"; then
    rm -f "$tmp_info" 2>/dev/null || true
    return 1
  fi

  fs_copy_file "$tmp_info" "$img_info"
  rm -f "$tmp_info" 2>/dev/null || true
  return 0
}

resolve_tooltype_revised_file() {
  local rel="${1:-}"
  rel="$(normalize_rel_path "$rel")"
  [[ -z "$rel" ]] && return 1

  if [[ -f "$rel" ]]; then
    echo "$rel"
    return 0
  fi

  local cand
  for cand in \
    "$ASSETS_DIR/AmigaFiles/System/$rel" \
    "$ASSETS_DIR/AmigaFiles/$rel" \
    "$ASSETS_DIR/$rel"
  do
    if [[ -f "$cand" ]]; then
      echo "$cand"
      return 0
    fi
  done

  return 1
}

apply_tooltype_overrides_in_place() {
  local info_path="$1"
  local mode_raw="$2"      # modify | replace
  local revised_file="$3"  # host path with revised tooltypes

  [[ -f "$info_path" ]] || return 1
  [[ -f "$revised_file" ]] || return 1

  local mode
  mode="$(echo "$(trim_spaces "$mode_raw")" | tr '[:upper:]' '[:lower:]')"
  [[ "$mode" == "replace" ]] || mode="modify"

  local tmp_new="$STAGE/tooltypes-new.$$.$RANDOM"
  local tmp_existing="$STAGE/tooltypes-existing.$$.$RANDOM"
  local tmp_merged="$STAGE/tooltypes-merged.$$.$RANDOM"

  if head -n1 "$revised_file" | grep -q ';'; then
    awk -F';' '
      function trim(s){ gsub(/\r/, "", s); sub(/^[ \t"]+/, "", s); sub(/[ \t"]+$/, "", s); return s }
      function norm(s){ s=tolower(trim(s)); gsub(/[^a-z0-9]/, "", s); return s }
      NR==1{
        for(i=1;i<=NF;i++) h[norm($i)] = i
        if (!(h["newvalue"] || h["newtooltype"] || h["tooltype"] || h["value"])) {
          v = trim($1)
          if (v != "") print v
        }
        next
      }
      {
        v = ""
        if (h["newvalue"])      v = trim($(h["newvalue"]))
        else if (h["newtooltype"]) v = trim($(h["newtooltype"]))
        else if (h["value"])    v = trim($(h["value"]))
        else if (h["tooltype"]) v = trim($(h["tooltype"]))
        else                    v = trim($1)
        if (v != "") print v
      }
    ' "$revised_file" > "$tmp_new"
  else
    awk '
      { gsub(/\r/, "", $0) }
      /^[[:space:]]*$/ { next }
      /^[[:space:]]*[#;]/ { next }
      { print }
    ' "$revised_file" > "$tmp_new"
  fi

  if [[ ! -s "$tmp_new" ]]; then
    rm -f "$tmp_new" "$tmp_existing" "$tmp_merged" 2>/dev/null || true
    return 1
  fi

  if [[ "$mode" == "replace" ]]; then
    cp "$tmp_new" "$tmp_merged"
  else
    if ! export_tooltypes "$info_path" "$tmp_existing"; then
      : > "$tmp_existing"
    fi
    cp "$tmp_existing" "$tmp_merged"

    while IFS= read -r newline || [[ -n "$newline" ]]; do
      newline="$(trim_spaces "$(printf "%s" "$newline" | tr -d '\r')")"
      [[ -z "$newline" ]] && continue
      local key
      key="$(trim_spaces "${newline%%=*}")"
      if [[ -n "$key" ]]; then
        awk -v key="$key" '
          BEGIN { IGNORECASE=1 }
          {
            raw=$0
            gsub(/\r/, "", raw)
            t=raw
            sub(/^[ \t]+/, "", t)
            split(t, a, "=")
            if (toupper(a[1]) == toupper(key)) next
            print raw
          }
        ' "$tmp_merged" > "$tmp_merged.next"
        mv "$tmp_merged.next" "$tmp_merged"
      fi
      printf "%s\n" "$newline" >> "$tmp_merged"
    done < "$tmp_new"
  fi

  make_lf_only "$tmp_merged"
  if ! import_tooltypes "$info_path" "$tmp_merged"; then
    rm -f "$tmp_new" "$tmp_existing" "$tmp_merged" 2>/dev/null || true
    return 1
  fi

  rm -f "$tmp_new" "$tmp_existing" "$tmp_merged" 2>/dev/null || true
  return 0
}

apply_tooltypes_from_package_csv() {
  local csv_path
  csv_path="$(find_input_csv "ListofPackagestoInstall.CSV" || true)"
  if [[ -z "$csv_path" ]]; then
    echo "==> Tooltypes: ListofPackagestoInstall.CSV not found (skipping)"
    return 0
  fi

  local rows="$STAGE/tooltype-rows.tsv"
  awk -F';' '
    function trim(s){ gsub(/\r/, "", s); sub(/^[ \t"]+/, "", s); sub(/[ \t"]+$/, "", s); return s }
    function norm(s){ s=tolower(trim(s)); gsub(/[^a-z0-9]/, "", s); return s }
    function get(k,   idx,val){ idx=h[k]; if (idx == 0) return ""; val=trim($(idx)); return val }
    NR==1{
      for(i=1;i<=NF;i++) h[norm($i)] = i
      next
    }
    {
      drive=get("drivetoinstall")
      loc=get("locationtoinstall")
      src=get("filestoinstall")
      newf=get("newfilename")
      mtype=get("modifyinfofiletype")
      mtt=get("modifyinfofiletooltype")
      rev=get("pathtorevisedtooltypeinfo")
      if (tolower(mtype) == "false") mtype=""
      if (tolower(mtt) == "false") mtt=""
      if (mtype == "" && mtt == "") next
      # Use pipe delimiter - shell IFS collapses consecutive whitespace but preserves non-whitespace delimiters
      print drive "|" loc "|" src "|" newf "|" mtype "|" mtt "|" rev
    }
  ' "$csv_path" > "$rows"

  local total=0 updated=0 skipped=0
  while IFS='|' read -r drive loc src_file new_name mtype mtt rev; do
    [[ -z "$drive$loc$src_file$new_name$mtype$mtt$rev" ]] && continue
    total=$((total + 1))

    local root
    root="$(resolve_drive_root "$drive" || true)"
    if [[ -z "$root" ]]; then
      skipped=$((skipped + 1))
      continue
    fi

    local icon_name
    icon_name="$(trim_spaces "$new_name")"
    if [[ -z "$icon_name" ]]; then
      icon_name="$(basename "$(trim_spaces "$src_file")")"
    fi
    [[ -z "$icon_name" ]] && { skipped=$((skipped + 1)); continue; }

    local loc_norm
    loc_norm="$(normalize_rel_path "$loc")"
    local img_info="$root"
    [[ -n "$loc_norm" ]] && img_info="$img_info/$loc_norm"
    img_info="$img_info/$icon_name"

    local tmp_info="$STAGE/tooltype-update-$(basename "$icon_name").$$.$RANDOM.info"
    if ! fs_pull_file "$img_info" "$tmp_info"; then
      skipped=$((skipped + 1))
      continue
    fi

    local changed=0
    local type_num
    type_num="$(icon_type_to_num "$mtype")"
    if [[ -n "$type_num" ]]; then
      if hst_icon_update_in_place "$tmp_info" "$type_num"; then
        changed=1
      fi
    fi

    local mode_lower revised_path
    mode_lower="$(echo "$(trim_spaces "$mtt")" | tr '[:upper:]' '[:lower:]')"
    if [[ -n "$mode_lower" && "$mode_lower" != "false" ]]; then
      revised_path="$(resolve_tooltype_revised_file "$rev" || true)"
      if [[ -n "$revised_path" ]]; then
        if apply_tooltype_overrides_in_place "$tmp_info" "$mode_lower" "$revised_path"; then
          changed=1
        fi
      fi
    fi

    if [[ "$changed" -eq 1 ]]; then
      fs_copy_file "$tmp_info" "$img_info"
      updated=$((updated + 1))
    else
      skipped=$((skipped + 1))
    fi

    rm -f "$tmp_info" 2>/dev/null || true
  done < "$rows"

  echo "==> Tooltypes: processed=$total updated=$updated skipped=$skipped (from $(basename "$csv_path"))"
}

apply_icon_positions_from_csv() {
  local csv_path
  csv_path="$(find_input_csv "IconPositions.CSV" || true)"
  if [[ -z "$csv_path" ]]; then
    echo "==> Icon positions: IconPositions.CSV not found (skipping)"
    return 0
  fi

  local rows="$STAGE/iconpos-rows.tsv"
  awk -F';' -v wb_ver="$DETECTED_WB_VERSION" '
    function trim(s){ gsub(/\r/, "", s); sub(/^[ \t"]+/, "", s); sub(/[ \t"]+$/, "", s); return s }
    function norm(s){ s=tolower(trim(s)); gsub(/[^a-z0-9]/, "", s); return s }
    function get(k,   idx,val){ idx=h[k]; if (idx == 0) return ""; val=trim($(idx)); return val }
    # Map sub-version to major family: 3.2.2.1 -> 3.2, 3.1.4 -> 3.1
    function ver_family(v,  parts,n){
      n = split(v, parts, ".")
      if (n >= 2) return parts[1] "." parts[2]
      return v
    }
    # Check if wb_ver matches any version in a comma-separated list
    # Matches: exact, CSV family == wb_ver, CSV value == wb_ver family, both families equal
    function ver_matches(csv_versions,  n,arr,i,v,wf){
      wf = ver_family(wb_ver)
      n = split(csv_versions, arr, ",")
      for (i = 1; i <= n; i++) {
        v = trim(arr[i])
        if (v == wb_ver) return 1
        if (v == wf) return 1
        if (ver_family(v) == wf) return 1
      }
      return 0
    }
    NR==1{
      for(i=1;i<=NF;i++) h[norm($i)] = i
      next
    }
    {
      # Include filter: skip rows where Include is explicitly FALSE
      inc = get("include")
      if (inc != "" && toupper(inc) != "TRUE") next

      # KickstartVersion filter: skip rows that specify a version not matching ours
      kv = get("kickstartversion")
      if (kv != "" && !ver_matches(kv)) next

      drive=get("drive")
      file=get("file")
      typ=get("type")
      x=get("iconx")
      y=get("icony")
      dx=get("drawerx")
      dy=get("drawery")
      dw=get("drawerwidth")
      dh=get("drawerheight")
      if (file == "") next
      # Use pipe delimiter - shell IFS collapses consecutive whitespace but preserves non-whitespace delimiters
      print drive "|" file "|" typ "|" x "|" y "|" dx "|" dy "|" dw "|" dh
    }
  ' "$csv_path" > "$rows"

  local matched_rows
  matched_rows="$(wc -l < "$rows" | tr -d ' ')"
  echo "==> Icon positions: $matched_rows rows matched for WB version $DETECTED_WB_VERSION"

  local total=0 updated=0 skipped=0
  while IFS='|' read -r drive file_raw type_raw x y dx dy dw dh; do
    [[ -z "$file_raw" ]] && continue
    total=$((total + 1))

    local rel_path drive_from_file root
    rel_path="$file_raw"
    if [[ "$rel_path" == *:* ]]; then
      drive_from_file="${rel_path%%:*}"
      rel_path="${rel_path#*:}"
      if [[ -z "$(trim_spaces "$drive")" ]]; then
        drive="$drive_from_file"
      fi
    fi

    root="$(resolve_drive_root "$drive" || true)"
    if [[ -z "$root" ]]; then
      skipped=$((skipped + 1))
      continue
    fi

    rel_path="$(normalize_rel_path "$rel_path")"
    [[ -z "$rel_path" ]] && { skipped=$((skipped + 1)); continue; }

    local type_num
    type_num="$(icon_type_to_num "$type_raw")"

    if [[ -z "$type_num" && -z "$x" && -z "$y" && -z "$dx" && -z "$dy" && -z "$dw" && -z "$dh" ]]; then
      skipped=$((skipped + 1))
      continue
    fi

    # Amiga icon files are stored as <name>.info — append if not already present
    local info_path="$root/$rel_path"
    [[ "$info_path" != *.info ]] && info_path="${info_path}.info"

    if apply_icon_update_to_image_file "$info_path" "$type_num" "$x" "$y" "$dx" "$dy" "$dw" "$dh"; then
      updated=$((updated + 1))
    else
      skipped=$((skipped + 1))
    fi
  done < "$rows"

  echo "==> Icon positions: processed=$total updated=$updated skipped=$skipped (from $(basename "$csv_path"))"
}

apply_icon_positions_from_csv_scoped() {
  local scope_roots=("$@")
  if [[ "${#scope_roots[@]}" -eq 0 ]]; then
    echo "==> Icon positions: scoped mode requested with no scope roots (falling back to global)"
    apply_icon_positions_from_csv
    return 0
  fi

  local csv_path
  csv_path="$(find_input_csv "IconPositions.CSV" || true)"
  if [[ -z "$csv_path" ]]; then
    echo "==> Icon positions: IconPositions.CSV not found (skipping)"
    return 0
  fi

  local rows="$STAGE/iconpos-rows.tsv"
  awk -F';' -v wb_ver="$DETECTED_WB_VERSION" '
    function trim(s){ gsub(/\r/, "", s); sub(/^[ \t"]+/, "", s); sub(/[ \t"]+$/, "", s); return s }
    function norm(s){ s=tolower(trim(s)); gsub(/[^a-z0-9]/, "", s); return s }
    function get(k,   idx,val){ idx=h[k]; if (idx == 0) return ""; val=trim($(idx)); return val }
    function ver_family(v,  parts,n){
      n = split(v, parts, ".")
      if (n >= 2) return parts[1] "." parts[2]
      return v
    }
    function ver_matches(csv_versions,  n,arr,i,v,wf){
      wf = ver_family(wb_ver)
      n = split(csv_versions, arr, ",")
      for (i = 1; i <= n; i++) {
        v = trim(arr[i])
        if (v == wb_ver) return 1
        if (v == wf) return 1
        if (ver_family(v) == wf) return 1
      }
      return 0
    }
    NR==1{
      for(i=1;i<=NF;i++) h[norm($i)] = i
      next
    }
    {
      inc = get("include")
      if (inc != "" && toupper(inc) != "TRUE") next

      kv = get("kickstartversion")
      if (kv != "" && !ver_matches(kv)) next

      drive=get("drive")
      file=get("file")
      typ=get("type")
      x=get("iconx")
      y=get("icony")
      dx=get("drawerx")
      dy=get("drawery")
      dw=get("drawerwidth")
      dh=get("drawerheight")
      if (file == "") next
      print drive "|" file "|" typ "|" x "|" y "|" dx "|" dy "|" dw "|" dh
    }
  ' "$csv_path" > "$rows"

  local matched_rows
  matched_rows="$(wc -l < "$rows" | tr -d ' ')"
  echo "==> Icon positions: scoped pass matched $matched_rows base rows for WB version $DETECTED_WB_VERSION"

  local total=0 updated=0 skipped=0 out_of_scope=0
  while IFS='|' read -r drive file_raw type_raw x y dx dy dw dh; do
    [[ -z "$file_raw" ]] && continue

    local rel_path drive_from_file root
    rel_path="$file_raw"
    if [[ "$rel_path" == *:* ]]; then
      drive_from_file="${rel_path%%:*}"
      rel_path="${rel_path#*:}"
      if [[ -z "$(trim_spaces "$drive")" ]]; then
        drive="$drive_from_file"
      fi
    fi

    rel_path="$(normalize_rel_path "$rel_path")"
    if ! _icon_row_in_scope "$rel_path" "${scope_roots[@]}"; then
      out_of_scope=$((out_of_scope + 1))
      continue
    fi

    total=$((total + 1))
    root="$(resolve_drive_root "$drive" || true)"
    if [[ -z "$root" ]]; then
      skipped=$((skipped + 1))
      continue
    fi

    [[ -z "$rel_path" ]] && { skipped=$((skipped + 1)); continue; }

    local type_num
    type_num="$(icon_type_to_num "$type_raw")"

    if [[ -z "$type_num" && -z "$x" && -z "$y" && -z "$dx" && -z "$dy" && -z "$dw" && -z "$dh" ]]; then
      skipped=$((skipped + 1))
      continue
    fi

    local info_path="$root/$rel_path"
    [[ "$info_path" != *.info ]] && info_path="${info_path}.info"

    if apply_icon_update_to_image_file "$info_path" "$type_num" "$x" "$y" "$dx" "$dy" "$dw" "$dh"; then
      updated=$((updated + 1))
    else
      skipped=$((skipped + 1))
    fi
  done < "$rows"

  echo "==> Icon positions: processed=$total updated=$updated skipped=$skipped out_of_scope=$out_of_scope (from $(basename "$csv_path"))"
}

apply_icon_positions_pass2() {
  local mode
  mode="$(normalize_icon_pass2_mode "${ICON_PASS2_MODE:-scoped}")"

  if [[ "$mode" == "global" ]]; then
    echo "==> Icon positions: pass 2 mode=global"
    apply_icon_positions_from_csv
    return 0
  fi

  if [[ "${_BPLAN_LOADED:-0}" -ne 1 ]]; then
    echo "==> Icon positions: pass 2 mode=scoped requested but no build plan loaded; falling back to global"
    apply_icon_positions_from_csv
    return 0
  fi

  local scope_roots=()
  local root
  while IFS= read -r root; do
    [[ -n "$root" ]] && scope_roots+=("$root")
  done < <(_bplan_collect_touched_roots)

  if [[ "${#scope_roots[@]}" -eq 0 ]]; then
    echo "==> Icon positions: pass 2 mode=scoped requested but no touched roots were declared; falling back to global"
    apply_icon_positions_from_csv
    return 0
  fi

  echo "==> Icon positions: pass 2 mode=scoped scope_roots=${#scope_roots[@]}"
  apply_icon_positions_from_csv_scoped "${scope_roots[@]}"
}

# --- Roadshow install (Windows parity) ---------------------------------------

# copy_roadshow_s_dir()
#   Copy Roadshow Workbench/S content but skip User-Startup and Network-Startup
#   (we manage those ourselves).
#   $1 = source S directory on host
#   $2 = destination S directory in image

# ===========================================================================
# Roadshow / network helpers
# ===========================================================================
copy_roadshow_s_dir() {
  local src_dir="$1"
  local dst_dir="$2"

  [[ -d "$src_dir" ]] || return 0
  ensure_img_dir "$dst_dir"

  # Create destination directories first.
  while IFS= read -r rel; do
    rel="${rel#./}"
    case "$(basename "$rel")" in
      __MACOSX) continue ;;
    esac
    ensure_img_dir "$dst_dir/$rel"
  done < <(cd "$src_dir" && find . -type d -mindepth 1 -print | LC_ALL=C sort)

  # Copy files with filtering.
  (cd "$src_dir" && find . -type f -mindepth 1 -print0) | while IFS= read -r -d '' rel; do
    rel="${rel#./}"
    if is_skippable_overlay_file "$rel"; then
      continue
    fi
    case "$(basename "$rel")" in
      User-Startup|Network-Startup) continue ;;
    esac
    fs_copy_file "$src_dir/$rel" "$dst_dir/$rel"
  done
}

# install_imp3_prefs()
#   Copies the appropriate imp3.prefs variant (RTG or native) to SYS:S/.
#   IMP3 stores window positions/sizes in this file; the RTG variant uses
#   larger coordinates suited to high-res RTG screens, while the native
#   variant fits standard PAL/NTSC chipset resolutions.
#   $1 = sys_root
#   $2 = "1" if RTG is active, "0" for native chipset
install_imp3_prefs() {
  local sys_root="$1"
  local is_rtg="${2:-0}"

  local prefs_src=""
  if [[ "$is_rtg" == "1" ]]; then
    prefs_src="$ASSETS_DIR/imp3-prefs/imp3.prefs.rtg"
  else
    prefs_src="$ASSETS_DIR/imp3-prefs/imp3.prefs.native"
  fi

  if [[ -f "$prefs_src" ]]; then
    ensure_img_dir "$sys_root/S"
    fs_copy_file "$prefs_src" "$sys_root/S/imp3.prefs"
  fi
}

# install_nic_driver_libs()
#   Some SANA-II driver archives (notably 3c589.lha) ship user-space support
#   libraries in a sibling Libs/ directory that must be copied to SYS:Libs/
#   alongside the .device file. Without them the device init fails at
#   OpenLibrary() time — e.g. 3c589.device on A600 opens "pccard.library"
#   from LIBS: and the whole stack silently falls over when it is missing.
#   This helper walks a driver extract tree and copies every *.library file
#   found under any Libs/ subdir to SYS:Libs/.
#   $1 = sys_root
#   $2 = extract_dir (the root of the extracted driver archive)
install_nic_driver_libs() {
  local sys_root="$1"
  local extract_dir="$2"
  [[ -d "$extract_dir" ]] || return 0

  local lib_file rel_name installed=0
  while IFS= read -r -d '' lib_file; do
    rel_name="$(basename "$lib_file")"
    ensure_img_dir "$sys_root/Libs"
    fs_copy_file "$lib_file" "$sys_root/Libs/$rel_name"
    echo "==> Installed NIC driver library: Libs/$rel_name ($(du -k "$lib_file" | cut -f1) KB)"
    installed=$((installed + 1))
  done < <(find "$extract_dir" -type d \( -iname "Libs" -o -iname "LIBS" \) -print0 2>/dev/null \
           | xargs -0 -I{} find {} -maxdepth 2 -type f -iname "*.library" -print0 2>/dev/null)

  return 0
}

# extract_roadshow_bsdsocket()
#   When Roadshow network stack is selected, find the Roadshow LHA archive,
#   install core Roadshow files into SYS:, and extract bsdsocket.library.
#   Searches for demo or full version depending on ROADSHOW_VARIANT.
#   $1 = sys_root
#   $2 = is_68020plus (optional: "1" for 68020+ targets, "0" for stock 68000).
#        Defaults to "0" (safe: root variants are 68000-compatible, the 020
#        binaries fail OpenLibrary on 68000/68010). Mirror Roadshow's own
#        Install_Roadshow #special-version flag which drives bsdsocket.library
#        and both ppp-*.device files together.
extract_roadshow_bsdsocket() {
  local sys_root="$1"
  local is_68020plus="${2:-0}"

  [[ "${NETWORK_STACK:-}" == "roadshow" ]] || return 0

  local variant="${ROADSHOW_VARIANT:-full}"

  # Search for Roadshow LHA in multiple locations
  local roadshow_lha=""

  # 0) Explicit Roadshow archive path (preferred)
  if [[ -n "${ROADSHOW_ARCHIVE:-}" ]]; then
    if [[ -f "$ROADSHOW_ARCHIVE" ]]; then
      roadshow_lha="$ROADSHOW_ARCHIVE"
    else
      echo "==> WARN: Roadshow archive not found at: $ROADSHOW_ARCHIVE"
    fi
  fi

  # 1) Aminet staging dir
  if [[ -z "$roadshow_lha" && -n "${AMINET_DIR:-}" && -d "${AMINET_DIR:-}" ]]; then
    if [[ "$variant" == "demo" ]]; then
      for cand in "$AMINET_DIR"/RoadshowDemo*.lha "$AMINET_DIR"/Roadshow-Demo*.lha "$AMINET_DIR"/roadshowdemo*.lha; do
        [[ -f "$cand" ]] && roadshow_lha="$cand" && break
      done
    fi
    if [[ -z "$roadshow_lha" ]]; then
      for cand in "$AMINET_DIR"/Roadshow*.lha "$AMINET_DIR"/roadshow*.lha; do
        [[ -f "$cand" ]] && roadshow_lha="$cand" && break
      done
    fi
  fi

  # 2) Assets / LocalAmigaPackages (skip in download-only mode)
  if [[ -z "$roadshow_lha" && "${DOWNLOAD_ONLY:-0}" != "1" ]]; then
    if [[ "$variant" == "demo" ]]; then
      for cand in \
        "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/"RoadshowDemo*.lha \
        "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/"Roadshow-Demo*.lha \
        "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/"roadshowdemo*.lha \
        "$ASSETS_DIR/AmigaFiles/Packages/Roadshow/"RoadshowDemo*.lha \
        "$ASSETS_DIR/AmigaFiles/Packages/Roadshow/"Roadshow-Demo*.lha; do
        [[ -f "$cand" ]] && roadshow_lha="$cand" && break
      done
    fi
    if [[ -z "$roadshow_lha" ]]; then
      for cand in \
        "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/"Roadshow*.lha \
        "$ASSETS_DIR/AmigaFiles/Packages/Roadshow/"Roadshow*.lha \
        "$ASSETS_DIR/AmigaFiles/Packages/Roadshow/"roadshow*.lha; do
        [[ -f "$cand" ]] && roadshow_lha="$cand" && break
      done
    fi
  fi

  if [[ -z "$roadshow_lha" ]]; then
    echo "==> WARN: Roadshow ($variant) selected but LHA archive not found; bsdsocket.library not extracted"
    echo "==> Provide Roadshow.lha (or RoadshowDemo.lha) via the app or Assets/LocalAmigaPackages/"
    return 0
  fi

  echo "==> Using Roadshow variant: $variant"

  echo "==> Extracting Roadshow from: $(basename "$roadshow_lha")"
  local tmp_rs="$STAGE/roadshow-extract"
  rm -rf "$tmp_rs" 2>/dev/null || true
  mkdir -p "$tmp_rs"

  # Extract using hst-imager (works on LHA archives)
  "$HST_IMAGER" fs extract "$roadshow_lha" "$tmp_rs" --recursive --force 2>/dev/null || \
  "$HST_IMAGER" fs extract "$roadshow_lha" "$tmp_rs" --recursive 2>/dev/null || {
    echo "==> WARN: Failed to extract Roadshow LHA with hst-imager"
    # Try system lha if available
    if command -v lha >/dev/null 2>&1; then
      (cd "$tmp_rs" && lha x "$roadshow_lha") 2>/dev/null || true
    fi
  }

  # If the archive is a container, pick the most appropriate nested LHA.
  local content_lha=""
  local lhas=()
  while IFS= read -r -d '' f; do
    lhas+=("$f")
  done < <(find "$tmp_rs" -maxdepth 1 -type f -iname "*.lha" -print0 2>/dev/null)

  if [[ "${#lhas[@]}" -gt 0 ]]; then
    if [[ "$variant" == "demo" ]]; then
      for f in "${lhas[@]}"; do
        if echo "$f" | grep -qi "demo"; then content_lha="$f"; break; fi
      done
    fi
    if [[ -z "$content_lha" ]]; then
      for f in "${lhas[@]}"; do
        if echo "$f" | grep -qi "roadshow" && ! echo "$f" | grep -qi "update\\|compact\\|demo"; then
          content_lha="$f"; break
        fi
      done
    fi
    [[ -z "$content_lha" ]] && content_lha="${lhas[0]}"
  fi

  local tmp_main="$tmp_rs"
  if [[ -n "$content_lha" ]]; then
    tmp_main="$STAGE/roadshow-main"
    rm -rf "$tmp_main" 2>/dev/null || true
    mkdir -p "$tmp_main"
    "$HST_IMAGER" fs extract "$content_lha" "$tmp_main" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$content_lha" "$tmp_main" --recursive 2>/dev/null || true
  fi

  # Locate Workbench root inside the extracted archive.
  local wb_dir root
  wb_dir="$(find "$tmp_main" -type d -name "Workbench" -print -quit 2>/dev/null || true)"
  if [[ -n "$wb_dir" ]]; then
    root="${wb_dir%/Workbench}"

    # Copy Roadshow Workbench components into SYS:
    if [[ -d "$root/Workbench/C" ]]; then
      fs_copy_tree_filtered "$root/Workbench/C" "$sys_root/C"
    fi
    if [[ -d "$root/Workbench/Libs" ]]; then
      fs_copy_tree_filtered "$root/Workbench/Libs" "$sys_root/Libs"
    fi
    if [[ -d "$root/Workbench/Devs" ]]; then
      fs_copy_tree_filtered "$root/Workbench/Devs" "$sys_root/Devs"
    fi
    if [[ -d "$root/Workbench/Locale" ]]; then
      fs_copy_tree_filtered "$root/Workbench/Locale" "$sys_root/Locale"
    fi
    if [[ -d "$root/Workbench/Storage" ]]; then
      fs_copy_tree_filtered "$root/Workbench/Storage" "$sys_root/Storage"
    fi
    if [[ -d "$root/Workbench/S" ]]; then
      copy_roadshow_s_dir "$root/Workbench/S" "$sys_root/S"
    fi

    # Select 68000 vs 68020+ variant of bsdsocket.library and the two PPP
    # devices. The root paths ship the 68000/68010-compatible builds; the
    # 020/ subdirs ship 68020+-optimized builds. A 68020+ binary on a stock
    # 68000 CPU fails OpenLibrary (Roadshow 1.11+ turns the old crash into a
    # clean open-failure), producing "bsdsocket.library V4 konnte nicht
    # geoeffnet werden" at boot on A500/A600/A2000. Mirror Roadshow's own
    # Install_Roadshow selection: all three files move together based on one
    # decision, and the safe default is the root (68000-compatible) variant.
    local variant_subdir=""
    local variant_label="68000"
    if [[ "$is_68020plus" == "1" ]]; then
      variant_subdir="020"
      variant_label="68020+"
    fi
    echo "==> Roadshow: selecting $variant_label variant"

    local bsd_lib=""
    if [[ -n "$variant_subdir" && -f "$root/Workbench/Libs/$variant_subdir/bsdsocket.library" ]]; then
      bsd_lib="$root/Workbench/Libs/$variant_subdir/bsdsocket.library"
    elif [[ -f "$root/Workbench/Libs/bsdsocket.library" ]]; then
      bsd_lib="$root/Workbench/Libs/bsdsocket.library"
    fi
    if [[ -n "$bsd_lib" ]]; then
      ensure_img_dir "$sys_root/Libs"
      fs_copy_file "$bsd_lib" "$sys_root/Libs/bsdsocket.library"
      echo "==> Installed bsdsocket.library ($variant_label) to SYS:Libs/"
    else
      echo "==> WARN: bsdsocket.library not found in Roadshow archive"
    fi

    # PPP devices: fs_copy_tree_filtered already copied the root variants
    # blindly. On 68020+ targets, overwrite them with the optimized builds so
    # all three Roadshow binaries stay in sync with the single CPU decision.
    local ppp_src_dir="$root/Workbench/Devs/Networks"
    if [[ -n "$variant_subdir" && -d "$ppp_src_dir/$variant_subdir" ]]; then
      ppp_src_dir="$ppp_src_dir/$variant_subdir"
    fi
    local ppp_dev
    for ppp_dev in ppp-ethernet.device ppp-serial.device; do
      if [[ -f "$ppp_src_dir/$ppp_dev" ]]; then
        ensure_img_dir "$sys_root/Devs/Networks"
        fs_copy_file "$ppp_src_dir/$ppp_dev" "$sys_root/Devs/Networks/$ppp_dev"
        echo "==> Installed $ppp_dev ($variant_label) to SYS:Devs/Networks/"
      fi
    done
  else
    echo "==> WARN: Roadshow Workbench folder not found after extraction"
  fi

  rm -rf "$tmp_rs" "$tmp_main" 2>/dev/null || true
}

# stage_p96_archive()
#   Extract Picasso96 archive at build time — iComp paid archive or Aminet
#   shareware fallback. Installs core libs, monitor drivers, tools, and prefs.
#   Files are placed directly into the image — no first-boot extraction needed.
#   $1 = sys_root
stage_p96_icomp_archive() {
  local sys_root="$1"
  local archive="${P96_ICOMP_ARCHIVE:-}"

  # Fallback chain: iComp archive → Aminet staging dir → download from Aminet
  if [[ -z "$archive" || ! -f "$archive" ]]; then
    if [[ -n "$archive" && ! -f "$archive" ]]; then
      echo "==> WARN: P96 iComp archive not found at '$archive'"
    fi
    archive=""
    # 1) Search Aminet staging directory for picasso96.lha
    if [[ -n "${AMINET_DIR:-}" ]]; then
      for cand in "$AMINET_DIR"/Picasso96.lha "$AMINET_DIR"/picasso96.lha; do
        [[ -f "$cand" ]] && archive="$cand" && break
      done
    fi
    # 2) Download Aminet shareware directly (same source as PC reference imager)
    if [[ -z "$archive" || ! -f "$archive" ]]; then
      echo "==> No P96 archive found — downloading Aminet shareware (same as PC reference)..."
      local _dl_lha="$STAGE/p96-aminet-dl/Picasso96.lha"
      mkdir -p "$(dirname "$_dl_lha")"
      if curl -fsSL --connect-timeout 15 --max-time 120 \
           -o "$_dl_lha" "http://aminet.net/driver/video/Picasso96.lha" 2>/dev/null; then
        archive="$_dl_lha"
        echo "==> Downloaded Aminet Picasso96.lha ($(wc -c < "$_dl_lha" | tr -d ' ') bytes)"
      else
        echo "==> WARN: Could not download Aminet P96 — RTG will not work"
        return 0
      fi
    fi
  fi

  local archive_name
  archive_name="$(basename "$archive")"
  echo "==> Extracting Picasso96 archive at build time: $archive_name"

  # Extract archive on host
  local tmp_p96="$STAGE/p96-extract"
  rm -rf "$tmp_p96" 2>/dev/null || true
  mkdir -p "$tmp_p96"

  # Try lha first (hst-imager has IndexOutOfRangeException on LHA archives on Intel macOS x64).
  # lhasa sometimes returns non-zero even when it successfully extracts files (unknown Amiga
  # attributes). Check for extracted content rather than relying on lha's exit code.
  local _p96_extracted=0
  local _lha_bin=""
  _lha_bin="$(find_lha)" || true
  if [[ -n "$_lha_bin" ]]; then
    (cd "$tmp_p96" && "$_lha_bin" x "$archive") || true
    if [[ -n "$(find "$tmp_p96" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
      _p96_extracted=1
    fi
  fi
  if (( _p96_extracted == 0 )); then
    "$HST_IMAGER" fs extract "$archive" "$tmp_p96" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$archive" "$tmp_p96" --recursive 2>/dev/null || true
  fi

  # Locate the extracted root (Picasso96Install/ or similar)
  local p96_root=""
  p96_root="$(find "$tmp_p96" -maxdepth 1 -type d -name "Picasso96*" | head -n1)"
  if [[ -z "$p96_root" ]]; then
    echo "==> WARN: No Picasso96* directory found after extraction (skipping P96)"
    rm -rf "$tmp_p96" 2>/dev/null || true
    return 0
  fi
  echo "==> P96 archive root: $(basename "$p96_root")"

  # ---------------------------------------------------------------------------
  # Detect rtg.library version EARLY — determines which core P96 libs to use.
  #
  # Emu68-tools' VideoCore.card (v1.x) implements the Aminet shareware .card
  # API (rtg.library v40). It is INCOMPATIBLE with iComp P96 v41+ which uses
  # a completely different .card driver ABI. If the user's archive contains
  # v41+ rtg.library, installing it would produce zero RTG screen modes because
  # VideoCore.card can't register with the v41+ API.
  #
  # When v41+ is detected we download the Aminet shareware Picasso96.lha
  # (same as the PC reference imager) which contains rtg.library v40.3945,
  # emulation.library, Picasso96API.library, and the monitor binary.
  # Non-core components (P96Prefs, Picasso96Mode, etc.) still come from the
  # user's archive since they are version-independent.
  # ---------------------------------------------------------------------------
  local _rtg_lib_ver=0
  local _rtg_lib_path=""
  for _cand in "$p96_root/Libs/Picasso96/rtg.library" \
               "$p96_root/libs/Picasso96/rtg.library" \
               "$p96_root/libs/picasso96/rtg.library"; do
    [[ -f "$_cand" ]] && _rtg_lib_path="$_cand" && break
  done
  if [[ -n "$_rtg_lib_path" ]]; then
    _rtg_lib_ver="$(strings "$_rtg_lib_path" 2>/dev/null \
      | sed -n 's/.*rtg\.library \([0-9][0-9]*\)\..*/\1/p' | head -n1)" || true
    _rtg_lib_ver="${_rtg_lib_ver:-0}"
  fi

  local _p96_core_root="$p96_root"   # where to get core P96 libs/monitors
  local _need_legacy_id=1

  # The v41+ incompatibility + v40 shareware fallback is ONLY for PiStorm's
  # VideoCore.card (Emu68-tools), which implements the 1999 Aminet shareware
  # (v40) .card API and is incompatible with the iComp v41+ ABI.
  # Classic builds (real P96 cards) and MiSTer (MiSTer.card, Oct 2020) both
  # require the v43+ iComp ABI → P96_TRUST_ARCHIVE_ABI=1 skips this fallback.
  if (( _rtg_lib_ver >= 41 )) && [[ "${P96_TRUST_ARCHIVE_ABI:-}" != "1" ]]; then
    echo "==> WARN: rtg.library v${_rtg_lib_ver} detected — INCOMPATIBLE with Emu68 VideoCore.card"
    echo "==>   VideoCore.card implements the Aminet shareware (v40) .card API."
    echo "==>   Downloading Aminet P96 v40 shareware for compatible core libraries..."
    _need_legacy_id=0   # default for v41+ (no VC4_LEGACY_ID); overridden if v40 fallback succeeds

    # Download the Aminet Picasso96.lha shareware — same source as the PC
    # reference imager. Contains rtg.library v40.3945 and all core P96 libs.
    local _sw_lha="$STAGE/p96-shareware-dl/Picasso96.lha"
    local _sw_extract="$STAGE/p96-shareware-extract"
    local _dl_ok=0
    mkdir -p "$(dirname "$_sw_lha")"
    rm -rf "$_sw_extract" 2>/dev/null || true
    mkdir -p "$_sw_extract"

    local _p96_aminet_url="http://aminet.net/driver/video/Picasso96.lha"
    echo "  -> Downloading Aminet Picasso96.lha..."
    if curl -fsSL --connect-timeout 15 --max-time 120 -o "$_sw_lha" "$_p96_aminet_url" 2>/dev/null; then
      _dl_ok=1
    fi

    if (( _dl_ok == 0 )); then
      echo "==> WARN: Could not download Aminet P96 shareware — RTG may not work"
      echo "==>   Falling back to provided archive (rtg.library v${_rtg_lib_ver})"
    else
      echo "  -> Extracting Aminet P96 shareware..."
      # hst-imager has IndexOutOfRangeException on LHA archives on Intel macOS x64.
      # lhasa sometimes returns non-zero even on success. Check directory content.
      local _sw_extracted=0
      local _sw_lha_bin=""
      _sw_lha_bin="$(find_lha)" || true
      if [[ -n "$_sw_lha_bin" ]]; then
        echo "  -> Using lha command ($_sw_lha_bin)..."
        (cd "$_sw_extract" && "$_sw_lha_bin" x "$_sw_lha") || true
        if [[ -n "$(find "$_sw_extract" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
          _sw_extracted=1
        fi
      fi
      if (( _sw_extracted == 0 )); then
        echo "  -> Using hst-imager..."
        "$HST_IMAGER" fs extract "$_sw_lha" "$_sw_extract" --recursive --force 2>/dev/null || \
        "$HST_IMAGER" fs extract "$_sw_lha" "$_sw_extract" --recursive 2>/dev/null || true
      fi
      local _sw_root=""
      _sw_root="$(find "$_sw_extract" -maxdepth 1 -type d -name "Picasso96*" | head -n1)"
      if [[ -n "$_sw_root" && -d "$_sw_root" ]]; then
        # Verify rtg.library version
        local _sw_ver=0
        local _sw_rtg=""
        for _c in "$_sw_root/Libs/Picasso96/rtg.library" \
                  "$_sw_root/libs/Picasso96/rtg.library"; do
          [[ -f "$_c" ]] && _sw_rtg="$_c" && break
        done
        if [[ -n "$_sw_rtg" ]]; then
          _sw_ver="$(strings "$_sw_rtg" 2>/dev/null \
            | sed -n 's/.*rtg\.library \([0-9][0-9]*\)\..*/\1/p' | head -n1)" || true
          _sw_ver="${_sw_ver:-0}"
        fi
        if (( _sw_ver >= 41 )); then
          echo "==> WARN: Aminet P96 has rtg.library v${_sw_ver} — expected v40"
          echo "==>   Using provided archive anyway. RTG screen modes may not work."
          rm -rf "$_sw_extract" 2>/dev/null || true
        else
          echo "==> Aminet P96 shareware: rtg.library v${_sw_ver} — compatible with VideoCore.card"
          _p96_core_root="$_sw_root"
          _need_legacy_id=1   # shareware v40 needs VC4_LEGACY_ID
        fi
      else
        echo "==> WARN: Picasso96Install directory not found in Aminet archive"
      fi
    fi
  elif [[ "${P96_TRUST_ARCHIVE_ABI:-}" == "1" ]]; then
    # Trusted-archive path: use the user's core as-is regardless of
    # rtg.library version. Classic uses this for real P96 cards that need the
    # matching iComp ABI. PiStorm also uses it when a modern VideoCore.card is
    # known to work with the newer core.
    echo "==> rtg.library v${_rtg_lib_ver:-unknown} — using user-supplied archive as-is"
    _need_legacy_id=0
  else
    echo "==> rtg.library v${_rtg_lib_ver:-unknown} — compatible with VideoCore.card (VC4_LEGACY_ID added)"
  fi

  # --- Libs/Picasso96API.library (from core source) ---
  if [[ -f "$_p96_core_root/Libs/Picasso96API.library" ]]; then
    ensure_img_dir "$sys_root/Libs"
    fs_copy_file "$_p96_core_root/Libs/Picasso96API.library" "$sys_root/Libs/Picasso96API.library"
  fi

  # --- Libs/Picasso96/ (rtg.library, emulation.library, .card + .chip drivers from core source) ---
  local _emu_lib_host=""
  if [[ -d "$_p96_core_root/Libs/Picasso96" ]]; then
    ensure_img_dir "$sys_root/Libs/Picasso96"
    while IFS= read -r -d '' _f; do
      local _bn
      _bn="$(basename "$_f")"
      [[ "$_bn" == "_UAEFSDB.___" ]] && continue
      fs_copy_file "$_f" "$sys_root/Libs/Picasso96/$_bn"
      [[ "$_bn" == "emulation.library" ]] && _emu_lib_host="$_f"
    done < <(find "$_p96_core_root/Libs/Picasso96" -maxdepth 1 -type f -print0)
  fi

  # --- Libs/cybergraphics.library (CyberGraphX API alias for ScummVM and other CGX programs) ---
  # P96's emulation.library implements the full CGX API jump table but registers itself as
  # "emulation.library", so OpenLibrary("cybergraphics.library") finds nothing in the system
  # list and falls back to loading LIBS:cybergraphics.library from disk. Placing a patched copy
  # there (ln_Name rewritten to "cybergraphics.library" via HUNK binary patch) lets any
  # CGX-dependent program (ScummVM, iBrowse plugin, etc.) open the library successfully —
  # because OpenLibrary does a second FindName after InitLib and the name must match exactly.
  if [[ -n "$_emu_lib_host" && -f "$_emu_lib_host" ]]; then
    local _cgx_tmp="$STAGE/cybergraphics.library.$$"
    cp "$_emu_lib_host" "$_cgx_tmp"
    if "$AMIGA_TOOLS" patch-library-name "$_cgx_tmp" "cybergraphics.library"; then
      ensure_img_dir "$sys_root/Libs"
      fs_copy_file "$_cgx_tmp" "$sys_root/Libs/cybergraphics.library"
      echo "==> P96: created Libs/cybergraphics.library (ln_Name patched for CGX API compatibility)"
    else
      echo "==> WARN: P96: patch-library-name failed — cybergraphics.library not installed"
    fi
    rm -f "$_cgx_tmp" 2>/dev/null || true
  fi

  # --- Devs/Monitors/ (from core source) ---
  # Some Classic cards ship card-specific monitor names and must NOT also keep
  # the generic P96 monitor in Devs/Monitors, otherwise Picasso96Mode can
  # attach modes to the wrong board and P96 may fail with the generic board
  # context on the next boot. Newer archives may name that generic monitor
  # Native/Generic/Altais instead of Picasso96.
  local _skip_named_classic_picasso96=0
  case "${RTG_CARD:-}" in
    VA2000|ZZ9000|PVision|VampireSAGA) _skip_named_classic_picasso96=1 ;;
  esac
  if [[ -d "$_p96_core_root/devs/monitors" ]]; then
    ensure_img_dir "$sys_root/Devs/Monitors"
    for _f in "$_p96_core_root/devs/monitors"/*; do
      [[ -f "$_f" ]] || continue
      local _bn
      _bn="$(basename "$_f")"
      [[ "$_bn" == "_UAEFSDB.___" ]] && continue
      # Generic P96 monitor binaries: install only for platforms that
      # explicitly want them. Classic enables this so real P96 cards
      # (ZZ9000, Picasso IV, CyberVision) can use them as the source for
      # card-specific monitor copies (e.g. Devs/Monitors/ZZ9000). PiStorm
      # must keep only the Videocore/Uaegfx aliases, and MiSTer ships its
      # own Devs/Monitors/MiSTer. Named Classic cards get the donor cached,
      # but not left active in Devs/Monitors.
      case "$_bn" in
        Picasso96|Picasso96.info|Native|Native.info|Generic|Generic.info|Altais|Altais.info)
          [[ "${P96_INSTALL_GENERIC_MONITOR:-}" != "1" ]] && continue
          [[ "$_skip_named_classic_picasso96" == "1" ]] && continue
          ;;
      esac
      fs_copy_file "$_f" "$sys_root/Devs/Monitors/$_bn"
    done
  fi
  if (( _skip_named_classic_picasso96 == 1 )); then
    local _p96_monitor_cache="$STAGE/p96-monitor-cache"
    rm -rf "$_p96_monitor_cache" 2>/dev/null || true
    mkdir -p "$_p96_monitor_cache"
    local _p96_monitor_cached=0
    for _mon_name in Picasso96 Picasso96.info Native Native.info Generic Generic.info Altais Altais.info; do
      if [[ -f "$_p96_core_root/devs/monitors/$_mon_name" ]]; then
        cp -f "$_p96_core_root/devs/monitors/$_mon_name" "$_p96_monitor_cache/$_mon_name" 2>/dev/null || true
        [[ -f "$_p96_monitor_cache/$_mon_name" ]] && _p96_monitor_cached=1
      fi
    done
    if (( _p96_monitor_cached == 0 )); then
      rm -rf "$_p96_monitor_cache" 2>/dev/null || true
    fi
  fi
  # Startup-Sequence_UAEGFX expects legacy monitor names (Videocore/Uaegfx).
  # Keep Picasso96 as-is, but also create aliases for the UAEGFX swap logic.
  # SKIP_VIDEOCORE_MONITOR: set by platform scripts (e.g. MiSTer) that provide
  # their own monitor driver and don't need the VideoCore/UAEGFX swap.
  if [[ "${SKIP_VIDEOCORE_MONITOR:-}" != "1" ]]; then
    if [[ -f "$_p96_core_root/devs/monitors/Picasso96" ]]; then
      ensure_img_dir "$sys_root/Devs/Monitors"
      ensure_img_dir "$sys_root/Storage/Monitors"
      fs_copy_file "$_p96_core_root/devs/monitors/Picasso96" "$sys_root/Devs/Monitors/Videocore"
      fs_copy_file "$_p96_core_root/devs/monitors/Picasso96" "$sys_root/Storage/Monitors/Uaegfx"
    fi
    if [[ -f "$_p96_core_root/devs/monitors/Picasso96.info" ]]; then
      ensure_img_dir "$sys_root/Devs/Monitors"
      ensure_img_dir "$sys_root/Storage/Monitors"
      fs_copy_file "$_p96_core_root/devs/monitors/Picasso96.info" "$sys_root/Devs/Monitors/Videocore.info"
      fs_copy_file "$_p96_core_root/devs/monitors/Picasso96.info" "$sys_root/Storage/Monitors/Uaegfx.info"
    fi
  else
    echo "==> Skipping Videocore/Uaegfx monitor install (platform provides its own)"
  fi
  # Apply monitor tooltypes used by the Emu68 setup so P96 can build
  # graphics contexts and screenmodes without running the interactive installer.
  local vc_tooltypes_txt="$ASSETS_DIR/AmigaFiles/System/Devs/Monitors/Videocore.info.txt"
  if [[ -f "$vc_tooltypes_txt" ]]; then
    local _tmp_vc_info="$STAGE/p96-videocore.info"
    local _tmp_uae_info="$STAGE/p96-uaegfx.info"
    local _tmp_tt_raw="$STAGE/vc-tooltypes-raw.txt"
    local _tmp_tt_norm="$STAGE/vc-tooltypes-norm.txt"
    local _tmp_tt_nobt="$STAGE/vc-tooltypes-nobt.txt"
    local _tmp_vc_tt="$STAGE/vc-tooltypes.txt"
    local _tmp_uae_tt="$STAGE/uae-tooltypes.txt"

    # Extract tooltypes from the CSV-style file and normalize.
    awk -F';' '
      function trim(s){ gsub(/\r/, "", s); sub(/^[ \t"]+/, "", s); sub(/[ \t"]+$/, "", s); return s }
      function norm(s){ s=tolower(trim(s)); gsub(/[^a-z0-9]/, "", s); return s }
      NR==1{
        for(i=1;i<=NF;i++) h[norm($i)] = i
        next
      }
      {
        v = ""
        if (h["newvalue"])      v = trim($(h["newvalue"]))
        else if (h["newtooltype"]) v = trim($(h["newtooltype"]))
        else if (h["value"])    v = trim($(h["value"]))
        else if (h["tooltype"]) v = trim($(h["tooltype"]))
        else                    v = trim($1)
        if (v != "") print v
      }
    ' "$vc_tooltypes_txt" > "$_tmp_tt_raw"

    # Strip BOM/CR, drop blanks, and remove any existing BOARDTYPE lines.
    LC_ALL=C sed -e '1s/^\xEF\xBB\xBF//' -e 's/\r$//' "$_tmp_tt_raw" | awk 'NF' > "$_tmp_tt_norm"
    grep -vi '^BOARDTYPE=' "$_tmp_tt_norm" > "$_tmp_tt_nobt" || true

    # Strip VC4_LEGACY_ID when NOT needed (iComp/modern rtg.library v41+
    # that we couldn't replace with v40 shareware).
    if (( _need_legacy_id == 0 )); then
      grep -vi '^VC4_LEGACY_ID$' "$_tmp_tt_nobt" > "$_tmp_tt_nobt.tmp" || true
      mv -f "$_tmp_tt_nobt.tmp" "$_tmp_tt_nobt"
    fi

    printf "BOARDTYPE=VideoCore\n" > "$_tmp_vc_tt"
    cat "$_tmp_tt_nobt" >> "$_tmp_vc_tt"
    printf "BOARDTYPE=Uaegfx\n" > "$_tmp_uae_tt"
    cat "$_tmp_tt_nobt" >> "$_tmp_uae_tt"

    if fs_pull_file "$sys_root/Devs/Monitors/Videocore.info" "$_tmp_vc_info"; then
      if apply_tooltype_overrides_in_place "$_tmp_vc_info" "replace" "$_tmp_vc_tt"; then
        fs_copy_file "$_tmp_vc_info" "$sys_root/Devs/Monitors/Videocore.info"
      fi
    fi

    if fs_pull_file "$sys_root/Storage/Monitors/Uaegfx.info" "$_tmp_uae_info"; then
      if apply_tooltype_overrides_in_place "$_tmp_uae_info" "replace" "$_tmp_uae_tt"; then
        fs_copy_file "$_tmp_uae_info" "$sys_root/Storage/Monitors/Uaegfx.info"
      fi
    fi

    rm -f "$_tmp_vc_info" "$_tmp_uae_info" "$_tmp_tt_raw" "$_tmp_tt_norm" \
      "$_tmp_tt_nobt" "$_tmp_vc_tt" "$_tmp_uae_tt" 2>/dev/null || true
  fi
  # NOTE: Devs/Picasso96Settings is NOT copied from the archive — the Assets
  # overlay provides the custom Emu68 settings file (step 6d.1).

  # --- Programs/Picasso96/ (AttachSettings, FliP96, debug tools, etc.) ---
  if [[ -d "$p96_root/picasso96" ]]; then
    ensure_img_dir "$sys_root/Programs/Picasso96"
    fs_copy_tree_filtered "$p96_root/picasso96" "$sys_root/Programs/Picasso96"
  fi
  # Folder icon
  if [[ -f "$p96_root/Picasso96.info" ]]; then
    ensure_img_dir "$sys_root/Programs"
    fs_copy_file "$p96_root/Picasso96.info" "$sys_root/Programs/Picasso96.info"
  fi

  # --- Prefs (P96Prefs, Picasso96Mode) ---
  for _pref in P96Prefs P96Prefs.info Picasso96Mode Picasso96Mode.info; do
    if [[ -f "$p96_root/prefs/$_pref" ]]; then
      ensure_img_dir "$sys_root/Prefs"
      fs_copy_file "$p96_root/prefs/$_pref" "$sys_root/Prefs/$_pref"
    fi
  done

  # --- Commodities/P96ScreenCx ---
  for _cx in P96ScreenCx P96ScreenCx.info; do
    if [[ -f "$p96_root/Commodities/$_cx" ]]; then
      ensure_img_dir "$sys_root/Commodities"
      fs_copy_file "$p96_root/Commodities/$_cx" "$sys_root/Commodities/$_cx"
    fi
  done

  # --- Locale/Catalogs (P96Prefs translations) ---
  if [[ -d "$p96_root/Locale/Catalogs" ]]; then
    while IFS= read -r -d '' _lang_dir; do
      local _lang
      _lang="$(basename "$_lang_dir")"
      [[ "$_lang" == "_UAEFSDB.___" ]] && continue
      if [[ -f "$_lang_dir/P96Prefs.catalog" ]]; then
        ensure_img_dir "$sys_root/Locale/Catalogs/$_lang"
        fs_copy_file "$_lang_dir/P96Prefs.catalog" "$sys_root/Locale/Catalogs/$_lang/P96Prefs.catalog"
      fi
    done < <(find "$p96_root/Locale/Catalogs" -mindepth 1 -maxdepth 1 -type d -print0)
  fi

  echo "==> Picasso96 installed at build time (no first-boot extraction needed)"
  # Preserve board settings templates for follow-up Classic card installers.
  local _p96_settings_cache="$STAGE/p96-settings-cache"
  rm -rf "$_p96_settings_cache" 2>/dev/null || true
  mkdir -p "$_p96_settings_cache"
  local _p96_settings_cached=0
  while IFS= read -r -d '' _settings_file; do
    cp -f "$_settings_file" "$_p96_settings_cache/$(basename "$_settings_file")" 2>/dev/null || continue
    _p96_settings_cached=1
  done < <(find "$p96_root" -type f -iname 'Picasso96Settings*' -print0 2>/dev/null)
  if (( _p96_settings_cached == 0 )); then
    rm -rf "$_p96_settings_cache" 2>/dev/null || true
  fi
  # Keep the extracted tree available for follow-up Classic card installers
  # (P-Vision, VA2000, ZZ9000). The build-level EXIT trap removes $STAGE.
}

# ---------------------------------------------------------------------------
# _patch_info_stack <host_path> <stack_bytes>
#   Patch the do_StackSize field (offset 0x4A) of an AmigaOS DiskObject .info
#   file in-place on the HOST filesystem, before it is copied to the image.
# ---------------------------------------------------------------------------
_patch_info_stack() {
  local _info="$1" _sz="$2"
  [[ -f "$_info" ]] || return 0
  "$AMIGA_TOOLS" patch-info-stack "$_info" "$_sz" 2>/dev/null || true
}

# _patch_info_type_to_drawer <host_path>
#   Patch the do_Type field (offset 0x30) to WBDRAWER (2) so a copied tool
#   icon shows as a cabinet drawer in Workbench, not a tool/wrench.
# ---------------------------------------------------------------------------
_patch_info_type_to_drawer() {
  local _info="$1"
  [[ -f "$_info" ]] || return 0
  "$AMIGA_TOOLS" patch-info-type-drawer "$_info" 2>/dev/null || true
}

# ---------------------------------------------------------------------------
# _aminet_patch_icon_stacks <interim_dir> <package_hint>
#   Called from install_aminet_package_buildtime before the image copy step.
#   Add a case here for any package whose default .info stack size is too small.
# ---------------------------------------------------------------------------
_aminet_patch_icon_stacks() {
  local _interim="$1" _hint="$2"
  case "$_hint" in
    iGame)
      local _ig_dir="$_interim/Programs/iGame"
      # Replace default icon with styled iGame1 icon
      if [[ -f "$_ig_dir/iGame1.info" ]]; then
        cp "$_ig_dir/iGame1.info" "$_ig_dir/iGame.info" 2>/dev/null || true
        echo "  -> Applied iGame1 icon style"
      fi
      # Remove all stray extras/icon files that the wildcard row dumped into the drawer
      rm -f "$_ig_dir/iGame1.info" "$_ig_dir/iGame2.info" "$_ig_dir/iGame3.info" \
            "$_ig_dir/iGame4.info" "$_ig_dir/igame_drawer.info" \
            "$_ig_dir/igame_drawer_3.0.info" 2>/dev/null || true
      # igame_drawer.info ends up at Programs/ root due to wildcard branch; rename to drawer icon
      if [[ -f "$_interim/Programs/igame_drawer.info" ]]; then
        mv "$_interim/Programs/igame_drawer.info" "$_interim/Programs/iGame.info" 2>/dev/null || true
        echo "  -> Installed iGame drawer icon"
      fi
      ;;
    LumiFTP)
      local _lf_dir="$_interim/Programs/LumiFTP"
      # Archive has no separate drawer icon — copy app icon and patch type to WBDRAWER
      if [[ -f "$_lf_dir/LumiFTP.info" && ! -f "$_interim/Programs/LumiFTP.info" ]]; then
        cp "$_lf_dir/LumiFTP.info" "$_interim/Programs/LumiFTP.info" 2>/dev/null || true
        _patch_info_type_to_drawer "$_interim/Programs/LumiFTP.info"
        echo "  -> Installed LumiFTP drawer icon"
      fi
      _patch_info_stack "$_lf_dir/LumiFTP.info" 65536
      ;;
  esac
}

# install_aminet_package_buildtime()
#   Extract an Aminet LHA and install files at build time using CSV mapping.
#   This matches the Windows Amiga Imager approach:
#     1. Extract LHA to temp folder
#     2. Copy matching files to INTERIM local folder (on host filesystem)
#     3. Copy entire interim folder to image in one operation
#   $1 = sys_root
#   $2 = lha_file (full path to the .lha file)
#   Returns 0 if package was installed, 1 if no CSV mapping found

# ===========================================================================
# Programs finalization (shared post-Aminet fixups)
# ===========================================================================

# finalize_programs_entries <sys_root>
#   Apply post-Aminet icon fixups that are identical across all platforms.
#   Must run after all Aminet packages have been staged to the image.
finalize_programs_entries() {
  local sys_root="$1"

  # AHI: archive ships AHI.info as Tool pointing to a subdirectory. Convert to
  # Drawer so clicking it opens the folder instead of failing with "not found".
  if fs_dir "$sys_root/Programs/AHI/AHI"; then
    local ahi_tmp="$STAGE/ahi-icon-fix.$$"
    if fs_pull_file "$sys_root/Programs/AHI/AHI.info" "$ahi_tmp.info" >/dev/null 2>&1; then
      if "$AMIGA_TOOLS" icon-update "$ahi_tmp.info" --type 2 2>/dev/null; then
        fs_copy_file "$ahi_tmp.info" "$sys_root/Programs/AHI/AHI.info"
        echo "==> Converted Programs/AHI/AHI.info from Tool to Drawer"
      fi
      rm -f "$ahi_tmp.info" 2>/dev/null || true
    fi
  fi

  # DOpus: archive ships DOpus.info as Tool. Convert to Drawer.
  if fs_dir "$sys_root/Programs/DOpus"; then
    local dopus_tmp="$STAGE/dopus-drawer-fix.$$"
    if fs_pull_file "$sys_root/Programs/DOpus.info" "$dopus_tmp.info" >/dev/null 2>&1; then
      if "$AMIGA_TOOLS" icon-update "$dopus_tmp.info" --type 2 2>/dev/null; then
        fs_copy_file "$dopus_tmp.info" "$sys_root/Programs/DOpus.info"
        echo "==> Converted Programs/DOpus.info from Tool to Drawer"
      fi
      rm -f "$dopus_tmp.info" 2>/dev/null || true
    fi
  fi

  # HippoPlayer / AmigaAMP: archives ship no top-level drawer icon. Create one
  # from def_drawer.info so the folder is visible in Workbench icon-view.
  for _no_icon_app in HippoPlayer AmigaAMP; do
    if fs_dir "$sys_root/Programs/$_no_icon_app"; then
      if ! fs_dir "$sys_root/Programs/$_no_icon_app.info"; then
        if fs_dir "$sys_root/Prefs/Env-Archive/Sys/def_drawer.info"; then
          local _icon_tmp_a="$STAGE/.no-icon-$_no_icon_app-$$.$RANDOM.info"
          if fs_pull_file "$sys_root/Prefs/Env-Archive/Sys/def_drawer.info" "$_icon_tmp_a" 2>/dev/null; then
            fs_copy_file "$_icon_tmp_a" "$sys_root/Programs/$_no_icon_app.info" 2>/dev/null || true
            rm -f "$_icon_tmp_a" 2>/dev/null || true
          else
            "$HST_IMAGER" fs copy "$sys_root/Prefs/Env-Archive/Sys/def_drawer.info" \
              "$sys_root/Programs/$_no_icon_app.info" --force >/dev/null 2>&1 || true
            rm -f "$_icon_tmp_a" 2>/dev/null || true
          fi
          echo "==> $_no_icon_app: created drawer icon from def_drawer.info"
        fi
      fi
    fi
  done

  # Ensure the Programs drawer window is tall enough to show 5 rows of icons
  # (deepest row at y=244 + ~50px icon height = ~300px content height).
  local prog_tmp="$STAGE/programs-info-resize.$$"
  if fs_pull_file "$sys_root/Programs.info" "$prog_tmp.info" >/dev/null 2>&1; then
    "$AMIGA_TOOLS" icon-update "$prog_tmp.info" --drawer-height 300 >/dev/null 2>&1 && \
      fs_copy_file "$prog_tmp.info" "$sys_root/Programs.info" && \
      echo "==> Programs drawer: window height set to 300"
    rm -f "$prog_tmp.info" 2>/dev/null || true
  fi
}

# verify_programs_entries <sys_root>
#   Warning-only check of the final SYS:Programs layout.
#   Does NOT fail the build — prints warnings for missing .info files or
#   Tool icons where a Drawer is expected.
verify_programs_entries() {
  local sys_root="$1"
  local _warn=0

  _vprog_warn() { echo "==> WARN: Programs: $*"; _warn=$(( _warn + 1 )); }

  if ! fs_dir "$sys_root/Programs"; then
    _vprog_warn "SYS:Programs drawer missing"
    return 0
  fi

  _check_drawer_info() {
    local app="$1"
    local tmp_v="$STAGE/vprog-${app}-$$.info"
    fs_dir "$sys_root/Programs/$app" || return 0
    if ! fs_pull_file "$sys_root/Programs/$app.info" "$tmp_v" >/dev/null 2>&1; then
      _vprog_warn "$app.info missing (drawer exists, no top-level icon)"
      return 0
    fi
    local dtype
    dtype=$("$AMIGA_TOOLS" check-info-type "${tmp_v}" 2>/dev/null || echo "")
    [[ "$dtype" == "1" ]] && _vprog_warn "$app.info is type TOOL (expected DRAWER)"
    rm -f "$tmp_v" 2>/dev/null || true
  }

  _check_drawer_info "AHI"
  _check_drawer_info "DOpus"
  _check_drawer_info "HippoPlayer"
  _check_drawer_info "Roadie"
  _check_drawer_info "iGame"
  _check_drawer_info "LumiFTP"
  _check_drawer_info "Ibrowse"
  _check_drawer_info "AmiSSL"
  _check_drawer_info "WBDock"
  _check_drawer_info "AmigaAMP"

  if [[ $_warn -eq 0 ]]; then
    echo "==> Programs layout OK"
  else
    echo "==> Programs layout: $_warn warning(s) — see above"
  fi
}

# ===========================================================================
# Aminet package installation (CSV-driven build-time)
# ===========================================================================
install_aminet_package_buildtime() {
  local sys_root="$1"
  local lha_file="$2"

  local lha_base lha_stem lha_safe
  lha_base="$(basename "$lha_file")"
  lha_stem="${lha_base%.*}"
  lha_safe="$(printf '%s' "$lha_stem" | tr -c 'A-Za-z0-9._-' '_')"

  local csv_path
  csv_path="$(resolve_aminet_rules_csv || true)"
  [[ -n "$csv_path" ]] || return 1

  local archive_norm package_hint package_norm
  archive_norm="$(normalize_csv_token "$lha_base")"
  package_hint="$(aminet_package_hint_for_archive "$lha_base" || true)"
  package_norm="$(normalize_csv_token "$package_hint")"

  # Parse CSV to get rows for this package
  local rows="$STAGE/aminet-buildtime-$lha_safe.tsv"
  awk -F';' -v want_archive="$archive_norm" -v want_pkg="$package_norm" '
    function trim(s){ gsub(/\r/, "", s); sub(/^[ \t"]+/, "", s); sub(/[ \t"]+$/, "", s); return s }
    function norm(s){ s=tolower(trim(s)); gsub(/[^a-z0-9]/, "", s); return s }
    function get(k,   idx,val){ idx=h[k]; if (idx == 0) return ""; val=trim($(idx)); return val }
    NR==1{
      for(i=1;i<=NF;i++) h[norm($i)] = i
      next
    }
    {
      src_type=tolower(get("source"))
      if (src_type ~ /^web/) {
        dl=get("filedownloadname")
        pkg=get("packagename")
        dl_norm=norm(dl)
        pkg_norm=norm(pkg)
        do_match = 0
        if (dl_norm == want_archive) do_match = 1
        if (want_pkg == "") { } else { if (pkg_norm == want_pkg) do_match = 1 }
        if (do_match == 1) {
          drive=get("drivetoinstall")
          loc=get("locationtoinstall")
          src=get("filestoinstall")
          newf=get("newfilename")
          copyrec=get("copyrecursive")
          script_target=get("scriptbeingmodified")
          script_mode=get("modifyscript")
          script_changes=get("scriptpathtochanges")

          do_print = 0
          if (src == "") { } else { do_print = 1 }
          if (script_changes == "") { } else { do_print = 1 }
          if (do_print == 1) {
            print drive "|" loc "|" src "|" newf "|" copyrec "|" script_target "|" script_mode "|" script_changes
          }
        }
      }
    }
  ' "$csv_path" > "$rows"

  [[ -s "$rows" ]] || return 1

  # Extract LHA on host
  local tmp_extract="$STAGE/aminet-extract-$lha_safe"
  rm -rf "$tmp_extract" 2>/dev/null || true
  mkdir -p "$tmp_extract"

  # Try lha first (hst-imager IndexOutOfRangeException bug on Intel macOS x64 LHA extraction).
  # lhasa may return non-zero even on success; check content rather than exit code.
  local _lha_iapb=""
  _lha_iapb="$(find_lha)" || true
  if [[ -n "$_lha_iapb" ]]; then
    (cd "$tmp_extract" && "$_lha_iapb" x "$lha_file") 2>/dev/null || true
  fi
  if [[ -z "$(find "$tmp_extract" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
    if ! "$HST_IMAGER" fs extract "$lha_file" "$tmp_extract" --recursive --force 2>/dev/null; then
      if ! "$HST_IMAGER" fs extract "$lha_file" "$tmp_extract" --recursive 2>/dev/null; then
        echo "    WARN: Failed to extract $lha_base"
        rm -rf "$tmp_extract" 2>/dev/null || true
        return 1
      fi
    fi
  fi

  # Windows-style two-stage approach:
  # Stage 1: Copy matching files to INTERIM folder on host filesystem
  # Stage 2: Copy entire interim folder to image
  local tmp_interim="$STAGE/aminet-interim-$lha_safe"
  rm -rf "$tmp_interim" 2>/dev/null || true
  mkdir -p "$tmp_interim"

  local files_staged=0
  local drive loc src_file new_name copy_recursive script_target script_mode script_changes
  local script_changes_to_copy=()

  while IFS='|' read -r drive loc src_file new_name copy_recursive script_target script_mode script_changes; do
    # Track script modifications for later (even when no drive/source is specified).
    if [[ -n "$script_target" && -n "$script_changes" ]]; then
      script_changes_to_copy+=("$script_target|$script_mode|$script_changes")
    fi

    # Skip file staging if no source file is provided.
    local src_trim
    src_trim="$(trim_spaces "$src_file")"
    [[ -n "$src_trim" ]] || continue

    local drive_upper
    drive_upper="$(printf "%s" "$(trim_spaces "$drive")" | tr '[:lower:]' '[:upper:]')"

    # Only handle SYS: drive for now
    case "$drive_upper" in
      SYS|SYSTEM|DH0|SDH0|WORKBENCH) ;;
      *) continue ;;
    esac

    local loc_norm src_norm
    loc_norm="$(normalize_rel_path "$loc")"
    src_norm="$(normalize_rel_path "$src_file")"

    # Stage 1: Copy matching files to interim folder
    if [[ -n "$src_norm" ]]; then
      local interim_dest="$tmp_interim"
      [[ -n "$loc_norm" ]] && interim_dest="$tmp_interim/$loc_norm"

      # Create interim destination directory
      mkdir -p "$interim_dest"

      # Check if it's a wildcard pattern
      if [[ "$src_norm" == *"*"* ]]; then
        # Handle wildcards like "SysInfo/*"
        # Use -ipath (case-insensitive) because lhasa may extract Amiga dirs in
        # lowercase (e.g. MUI/ → mui/) while CSV paths are uppercase.
        # Limit to the exact depth of the glob so we only match direct children of
        # the pattern prefix — avoids double-copying nested files/directories.
        local glob_pattern="$src_norm"
        local _glob_depth
        _glob_depth=$(printf '%s' "$glob_pattern" | awk -F'/' '{print NF}')
        while IFS= read -r -d '' src_match; do
          local src_basename
          src_basename="$(basename "$src_match")"
          [[ "$src_basename" == "_UAEFSDB.___" ]] && continue
          [[ "$src_basename" == ".DS_Store" ]] && continue
          [[ "$src_basename" == "__uae___"* ]] && continue
          [[ "$src_basename" == *.MWB.info ]] && continue

          if [[ -d "$src_match" ]]; then
            # Directory matched by wildcard.
            # If the dir name equals the dest dir name (e.g. wildcard "Roadie*"
            # matches dir "Roadie" and dest is "Programs/Roadie"), merge contents
            # to avoid nesting (Programs/Roadie/Roadie/).
            # Otherwise copy as subdirectory (e.g. "MUI/*" matches "MUI/Libs"
            # and dest is "Programs/MUI" → keep as Programs/MUI/Libs/).
            local _dest_base _dest_base_lc _src_basename_lc
            _dest_base="$(basename "$interim_dest")"
            _dest_base_lc="$(printf '%s' "$_dest_base" | tr '[:upper:]' '[:lower:]')"
            _src_basename_lc="$(printf '%s' "$src_basename" | tr '[:upper:]' '[:lower:]')"
            if [[ "$_src_basename_lc" == "$_dest_base_lc" ]]; then
              mkdir -p "$interim_dest"
              for _sub in "$src_match"/*; do
                [[ -e "$_sub" ]] || continue
                local _sub_base
                _sub_base="$(basename "$_sub")"
                [[ "$_sub_base" == "_UAEFSDB.___" ]] && continue
                [[ "$_sub_base" == ".DS_Store" ]] && continue
                cp -R "$_sub" "$interim_dest/$_sub_base" 2>/dev/null || true
              done
            else
              cp -R "$src_match" "$interim_dest/$src_basename" 2>/dev/null || true
            fi
          elif [[ -f "$src_match" ]]; then
            # File - copy to interim
            cp "$src_match" "$interim_dest/$src_basename" 2>/dev/null || true
          fi
          files_staged=$((files_staged + 1))
        done < <(find "$tmp_extract" -maxdepth "$_glob_depth" -mindepth "$_glob_depth" -ipath "$tmp_extract/$glob_pattern" -print0 2>/dev/null)
      else
        # Exact file path
        local src_path="$tmp_extract/$src_norm"
        if [[ -e "$src_path" ]]; then
          local dest_name
          if [[ -n "$(trim_spaces "$new_name")" ]]; then
            dest_name="$(normalize_rel_path "$new_name")"
            dest_name="${dest_name##*/}"
          else
            dest_name="$(basename "$src_norm")"
          fi

          if [[ -d "$src_path" ]]; then
            # Directory - copy recursively to interim
            cp -R "$src_path" "$interim_dest/$dest_name" 2>/dev/null || true
          else
            # File - copy to interim
            cp "$src_path" "$interim_dest/$dest_name" 2>/dev/null || true
          fi
          files_staged=$((files_staged + 1))
        fi
      fi
    fi

  done < "$rows"

  # Stage 2: Copy entire interim folder to image (Windows-style bulk copy)
  if [[ "$files_staged" -gt 0 ]]; then
    # Keep _UAEFSDB.___ — hst-imager reads these during fs copy to set Amiga protection bits
    find "$tmp_interim" -name ".DS_Store" -delete 2>/dev/null || true
    find "$tmp_interim" -name "__uae___*" -delete 2>/dev/null || true

    # Remove installer files - we're doing build-time installation so these aren't needed
    find "$tmp_interim" -type f -iname "Install" -delete 2>/dev/null || true
    find "$tmp_interim" -type f -iname "Install.info" -delete 2>/dev/null || true
    find "$tmp_interim" -type f -iname "Install *" -delete 2>/dev/null || true
    find "$tmp_interim" -type f -iname "Install *.info" -delete 2>/dev/null || true
    find "$tmp_interim" -type f -iname "*.installer" -delete 2>/dev/null || true
    find "$tmp_interim" -type f -iname "*.installer.info" -delete 2>/dev/null || true

    # Fix nested same-name directories (e.g. Programs/Roadie/Roadie/ → merge into
    # Programs/Roadie/). hst-imager has no fs delete, so we must fix this on the
    # host side before copying to the image.
    while IFS= read -r -d '' _nested_dir; do
      local _parent_dir _parent_name _nested_name
      _parent_dir="$(dirname "$_nested_dir")"
      _parent_name="$(basename "$_parent_dir")"
      _nested_name="$(basename "$_nested_dir")"
      if [[ "$(printf '%s' "$_parent_name" | tr '[:upper:]' '[:lower:]')" == "$(printf '%s' "$_nested_name" | tr '[:upper:]' '[:lower:]')" ]]; then
        # Merge contents up and remove nested directory
        for _nf in "$_nested_dir"/*; do
          [[ -e "$_nf" ]] || continue
          local _nfb
          _nfb="$(basename "$_nf")"
          [[ -e "$_parent_dir/$_nfb" ]] || cp -R "$_nf" "$_parent_dir/$_nfb"
        done
        rm -rf "$_nested_dir"
        echo "    cleaned up nested $_parent_name/$_nested_name/ directory"
      fi
    done < <(find "$tmp_interim" -mindepth 2 -type d -print0 2>/dev/null)

    # Set host execute permission on Amiga executables. hst-imager maps host
    # permissions to Amiga protection bits: without +x on the host file, the
    # Amiga binary gets no execute permission and cannot be run.
    for _exec_dir in C L S Devs Prefs Tools System Classes; do
      [[ -d "$tmp_interim/$_exec_dir" ]] || continue
      find "$tmp_interim/$_exec_dir" -type f ! -name "*.info" ! -name "_UAEFSDB.___" \
        -exec chmod +x {} + 2>/dev/null || true
    done
    # Also set +x on binaries inside Programs/ subdirectories (non-.info files
    # at the top level of each package, e.g. Programs/DOpus/DirectoryOpus)
    if [[ -d "$tmp_interim/Programs" ]]; then
      find "$tmp_interim/Programs" -maxdepth 2 -type f ! -name "*.info" ! -name "*.readme" \
        ! -name "*.txt" ! -name "*.doc" ! -name "*.guide" ! -name "*.readme.info" \
        ! -name "_UAEFSDB.___" ! -name "*.catalog" \
        -exec chmod +x {} + 2>/dev/null || true
    fi

    # Apply per-package .info stack size patches before copying to image.
    _aminet_patch_icon_stacks "$tmp_interim" "$package_hint"

    # One recursive copy is much faster than per-file fs copy calls.
    if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
      _disk_parse_path "$sys_root/"
      if [[ -n "$_dp_part" ]]; then
        find "$tmp_interim" -name '_UAEFSDB.___' -delete 2>/dev/null || true
        "$AMIGA_TOOLS" disk fs copy "$DISK_ENGINE_IMG" "$_dp_part" "$tmp_interim/" "${_dp_path:-}"
      else
        if ! "$HST_IMAGER" fs copy "$tmp_interim/" "$sys_root/" --recursive --force --makedir --quiet; then
          "$HST_IMAGER" fs copy "$tmp_interim/" "$sys_root/" --recursive --force --makedir
        fi
      fi
    else
      if ! "$HST_IMAGER" fs copy "$tmp_interim/" "$sys_root/" --recursive --force --makedir --quiet; then
        "$HST_IMAGER" fs copy "$tmp_interim/" "$sys_root/" --recursive --force --makedir
      fi
    fi
  fi

  # Handle User-Startup script modifications (guard against empty array)
  for script_entry in ${script_changes_to_copy[@]+"${script_changes_to_copy[@]}"}; do
    local script_target script_mode script_changes_path
    IFS='|' read -r script_target script_mode script_changes_path <<< "$script_entry"

    local script_target_norm script_changes_norm target_upper mode_lower
    script_target_norm="$(normalize_rel_path "$script_target")"
    script_changes_norm="$(normalize_rel_path "$script_changes_path")"
    target_upper="$(printf "%s" "$script_target_norm" | tr '[:lower:]' '[:upper:]')"
    mode_lower="$(printf "%s" "$script_mode" | tr '[:upper:]' '[:lower:]')"

    if [[ "$target_upper" == "S/USER-STARTUP" ]] && [[ "$mode_lower" != "false" ]]; then
      # Find the script changes file in extracted archive or Assets
      local changes_file=""
      if [[ -f "$tmp_extract/$script_changes_norm" ]]; then
        changes_file="$tmp_extract/$script_changes_norm"
      elif [[ -f "$ASSETS_DIR/AmigaFiles/$script_changes_norm" ]]; then
        changes_file="$ASSETS_DIR/AmigaFiles/$script_changes_norm"
      elif [[ -f "$ASSETS_DIR/AmigaFiles/System/$script_changes_norm" ]]; then
        changes_file="$ASSETS_DIR/AmigaFiles/System/$script_changes_norm"
      elif [[ -f "$ASSETS_DIR/../InputFiles/$script_changes_norm" ]]; then
        changes_file="$ASSETS_DIR/../InputFiles/$script_changes_norm"
      fi

      if [[ -n "$changes_file" && -f "$changes_file" ]]; then
        # Actually inject the overlay content into User-Startup (not just copy)
        local changes_basename pkg_marker
        changes_basename="$(basename "$script_changes_norm")"
        pkg_marker="${changes_basename#User-Startup_}"
        pkg_marker="${pkg_marker%.txt}"

        # First copy to S: for reference
        fs_copy_file "$changes_file" "$sys_root/S/$changes_basename" 2>/dev/null || true

        # Then inject into User-Startup
        local us_path="S/user-startup"
        if ! fs_dir "$sys_root/S/user-startup"; then
          if fs_dir "$sys_root/S/User-Startup"; then
            us_path="S/User-Startup"
          fi
        fi
        inject_overlay_into_script "$sys_root" "$us_path" "$changes_file" append "" "$pkg_marker" 2>/dev/null || true
      fi
    fi
  done

  # Cleanup
  rm -rf "$tmp_extract" 2>/dev/null || true
  rm -rf "$tmp_interim" 2>/dev/null || true

  if [[ "$files_staged" -gt 0 ]]; then
    echo "    installed $files_staged files at build time"
    return 0
  else
    return 1
  fi
}

normalize_roadie_install() {
  # Windows/SysInfo-style layout normalization for Roadie.
  # Keep executable/icon in canonical locations under SYS:Programs/Roadie.
  # NOTE: Nested same-name directories (Roadie/Roadie/) are now cleaned up
  # during host-side staging (install_aminet_package_buildtime). This function
  # remains as a safety net — it can copy files from nested locations but cannot
  # delete because hst-imager v1.4.x has no fs delete command.
  local sys_root="$1"
  local tmp_chk="$STAGE/roadie-normalize.$$"

  if ! fs_dir "$sys_root/Programs/Roadie"; then
    return 0
  fi

  echo "==> Normalizing Roadie install (Windows parity)"

  # Programs/Roadie.info: standard Workbench drawer icon (the archive only
  # ships a TOOL icon for the binary — confusing as a folder icon).
  local _roadie_drawer=""
  _roadie_drawer="$(_default_drawer_icon "$sys_root" || true)"
  if [[ -n "$_roadie_drawer" ]]; then
    fs_copy_file "$_roadie_drawer" "$sys_root/Programs/Roadie.info" || true
    echo "  -> Installed standard drawer icon as Programs/Roadie.info"
  elif ! fs_pull_file "$sys_root/Programs/Roadie.info" "$tmp_chk.info" >/dev/null 2>&1; then
    if fs_pull_file "$sys_root/Programs/Roadie/Roadie.info" "$tmp_chk.info" >/dev/null 2>&1; then
      fs_copy_file "$tmp_chk.info" "$sys_root/Programs/Roadie.info" || true
    fi
  fi

  # Guard against nested extraction layouts: Programs/Roadie/Roadie/#?
  # Use fs_pull_file checks here: hst-imager fs dir can report success for fuzzy paths.
  if ! fs_pull_file "$sys_root/Programs/Roadie/Roadie" "$tmp_chk.bin" >/dev/null 2>&1; then
    if fs_pull_file "$sys_root/Programs/Roadie/Roadie/Roadie" "$tmp_chk.bin" >/dev/null 2>&1; then
      fs_copy_file "$tmp_chk.bin" "$sys_root/Programs/Roadie/Roadie" || true
    fi
  fi
  if ! fs_pull_file "$sys_root/Programs/Roadie/Roadie.readme" "$tmp_chk.readme" >/dev/null 2>&1; then
    if fs_pull_file "$sys_root/Programs/Roadie/Roadie/Roadie.readme" "$tmp_chk.readme" >/dev/null 2>&1; then
      fs_copy_file "$tmp_chk.readme" "$sys_root/Programs/Roadie/Roadie.readme" || true
    fi
  fi
  if ! fs_pull_file "$sys_root/Programs/Roadie/Roadie.readme.info" "$tmp_chk.readme.info" >/dev/null 2>&1; then
    if fs_pull_file "$sys_root/Programs/Roadie/Roadie/Roadie.readme.info" "$tmp_chk.readme.info" >/dev/null 2>&1; then
      fs_copy_file "$tmp_chk.readme.info" "$sys_root/Programs/Roadie/Roadie.readme.info" || true
    fi
  fi

  rm -f "$tmp_chk.bin" "$tmp_chk.readme" "$tmp_chk.readme.info" "$tmp_chk.info" >/dev/null 2>&1 || true

  # Legacy safety net (only reachable when the standard drawer icon above was
  # unavailable): convert a Tool-type Programs/Roadie.info to Drawer so
  # clicking opens the drawer instead of trying to execute it.
  if [[ -z "$_roadie_drawer" ]]; then
    local tmp_ri="$STAGE/roadie-drawer-fix.$$"
    if fs_pull_file "$sys_root/Programs/Roadie.info" "$tmp_ri.info" >/dev/null 2>&1; then
      if "$AMIGA_TOOLS" icon-update "$tmp_ri.info" --type 2 2>/dev/null; then
        fs_copy_file "$tmp_ri.info" "$sys_root/Programs/Roadie.info"
        echo "  -> Converted Programs/Roadie.info from Tool to Drawer"
      fi
      rm -f "$tmp_ri.info" 2>/dev/null || true
    fi
  fi
}

# AHI post-install fixup: the AHI archive ships AHI_MUI and AHI_BGUI as
# separate preference programs. The icon AHI.info expects an executable
# named "AHI". The official AHI installer copies AHI_MUI → AHI (or
# AHI_BGUI as fallback). We replicate this here.
fixup_ahi_prefs() {
  local sys_root="$1"
  local tmp_ahi="$STAGE/ahi-prefs-fixup.$$"

  # Check if AHI prefs executables were installed
  local has_mui=0 has_bgui=0
  if fs_pull_file "$sys_root/Prefs/AHI_MUI" "$tmp_ahi.mui" >/dev/null 2>&1; then
    has_mui=1
  fi
  if fs_pull_file "$sys_root/Prefs/AHI_BGUI" "$tmp_ahi.bgui" >/dev/null 2>&1; then
    has_bgui=1
  fi

  # Prefer AHI_MUI (MUI is typically installed via MUI: assign at runtime).
  # Fall back to AHI_BGUI if AHI_MUI not available.
  if [[ "$has_mui" -eq 1 ]]; then
    fs_copy_file "$tmp_ahi.mui" "$sys_root/Prefs/AHI"
    echo "==> AHI: copied AHI_MUI -> Prefs/AHI"
  elif [[ "$has_bgui" -eq 1 ]]; then
    fs_copy_file "$tmp_ahi.bgui" "$sys_root/Prefs/AHI"
    echo "==> AHI: copied AHI_BGUI -> Prefs/AHI"
  fi

  rm -f "$tmp_ahi.mui" "$tmp_ahi.bgui" 2>/dev/null || true
}

install_ibrowse_key_file() {
  # Copy supplied IBrowse key to the installed IBrowse drawer.
  # $1 = sys_root
  # $2 = key_file (optional)
  # $3 = ibrowse_dir (optional, fallback lookup)
  local sys_root="$1"
  local key_file="${2:-}"
  local ibrowse_dir="${3:-}"

  local key_src=""
  if [[ -n "$key_file" && -f "$key_file" ]]; then
    key_src="$key_file"
  else
    local cand
    local search_paths=()
    if [[ -n "$ibrowse_dir" ]]; then
      search_paths+=(
        "$ibrowse_dir/IBrowse.key"
        "$ibrowse_dir/ibrowse.key"
        "$ibrowse_dir/IBrowse3.key"
        "$ibrowse_dir/IBrowse3Key"
      )
    fi
    search_paths+=(
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/IBrowse.key"
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/ibrowse.key"
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/IBrowse3.key"
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/IBrowse3Key"
    )
    for cand in "${search_paths[@]}"; do
      if [[ -f "$cand" ]]; then
        key_src="$cand"
        break
      fi
    done
  fi

  [[ -n "$key_src" ]] || return 0

  local prog_ib=""
  local cand_ib
  for cand_ib in "$sys_root/Programs/Ibrowse" "$sys_root/Programs/IBrowse" "$sys_root/Programs/ibrowse"; do
    if fs_dir "$cand_ib"; then
      prog_ib="$cand_ib"
      break
    fi
  done
  if [[ -z "$prog_ib" ]]; then
    echo "  -> WARN: IBrowse key provided, but SYS:Programs/Ibrowse was not found"
    return 0
  fi

  local key_name
  key_name="$(basename "$key_src")"
  echo "  -> Installing IBrowse key file: $key_name"

  fs_copy_file "$key_src" "$prog_ib/$key_name"
  fs_copy_file "$key_src" "$prog_ib/IBrowse.key"

  if ! img_dir_has_entry_ci "$prog_ib" "IBrowse.key"; then
    die "IBrowse key was provided but not copied to SYS:Programs/Ibrowse."
  fi
}

# ensure_p96_videocore_driver()
#   Ensure VideoCore/Uaegfx board drivers exist for PiStorm/UAE RTG.
#   These drivers are not present in the standard iComp Picasso96 archive.
#   We search LocalAmigaPackages and (optionally) the Aminet Picasso96 LHA.
#   $1 = sys_root
ensure_p96_videocore_driver() {
  local sys_root="$1"
  local tmp_check="$STAGE/p96-driver-check.$$.$RANDOM"
  local need_vc=1
  local need_uae=1

  if fs_pull_file "$sys_root/Libs/Picasso96/VideoCore.card" "$tmp_check" >/dev/null 2>&1; then
    need_vc=0
  fi
  if fs_pull_file "$sys_root/Libs/Picasso96/Uaegfx.card" "$tmp_check" >/dev/null 2>&1; then
    need_uae=0
  fi
  rm -f "$tmp_check" 2>/dev/null || true

  [[ "$need_vc" -eq 1 || "$need_uae" -eq 1 ]] || return 0

  echo "==> P96: checking for missing VideoCore/Uaegfx drivers"

  # 1) LocalAmigaPackages overrides (skip in download-only mode)
  if [[ "${DOWNLOAD_ONLY:-0}" != "1" ]]; then
    local driver_dirs=(
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/P96Drivers"
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages"
    )
    for src_dir in "${driver_dirs[@]}"; do
      [[ -d "$src_dir" ]] || continue
      if [[ "$need_vc" -eq 1 && -f "$src_dir/VideoCore.card" ]]; then
        echo "==> P96: using VideoCore.card from $src_dir"
        local tmp_vc="$STAGE/VideoCore.card.$$.$RANDOM"
        cp -f "$src_dir/VideoCore.card" "$tmp_vc"
        write_uaem_with_perms "----rwed" "$tmp_vc"
        ensure_img_dir "$sys_root/Libs/Picasso96"
        fs_copy_file "$tmp_vc" "$sys_root/Libs/Picasso96/VideoCore.card" --uaemetadata UaeMetafile
        need_vc=0
      fi
      if [[ "$need_uae" -eq 1 && -f "$src_dir/Uaegfx.card" ]]; then
        echo "==> P96: using Uaegfx.card from $src_dir"
        local tmp_uae="$STAGE/Uaegfx.card.$$.$RANDOM"
        cp -f "$src_dir/Uaegfx.card" "$tmp_uae"
        write_uaem_with_perms "----rwed" "$tmp_uae"
        ensure_img_dir "$sys_root/Libs/Picasso96"
        fs_copy_file "$tmp_uae" "$sys_root/Libs/Picasso96/Uaegfx.card" --uaemetadata UaeMetafile
        need_uae=0
      fi
    done
  fi

  # 2) Aminet Picasso96 shareware (if available)
  if [[ "$need_vc" -eq 1 || "$need_uae" -eq 1 ]]; then
    if [[ -n "${AMINET_DIR:-}" && -d "${AMINET_DIR:-}" ]]; then
      local aminet_p96=""
      for cand in "$AMINET_DIR"/Picasso96.lha "$AMINET_DIR"/picasso96.lha; do
        [[ -f "$cand" ]] && aminet_p96="$cand" && break
      done
      if [[ -n "$aminet_p96" ]]; then
        echo "==> P96: extracting drivers from Aminet $(basename "$aminet_p96")"
        local tmp_p96="$STAGE/p96-aminet-extract.$$.$RANDOM"
        rm -rf "$tmp_p96" 2>/dev/null || true
        mkdir -p "$tmp_p96"
        "$HST_IMAGER" fs extract "$aminet_p96" "$tmp_p96" --recursive --force 2>/dev/null || \
        "$HST_IMAGER" fs extract "$aminet_p96" "$tmp_p96" --recursive 2>/dev/null || {
          echo "==> WARN: Failed to extract Aminet Picasso96 LHA for driver scan"
        }

        if [[ -d "$tmp_p96" ]]; then
          if [[ "$need_vc" -eq 1 ]]; then
            local vc_card
            vc_card="$(find "$tmp_p96" -iname "VideoCore.card" 2>/dev/null | head -n1)"
            if [[ -n "$vc_card" ]]; then
              echo "==> P96: found VideoCore.card in Aminet shareware"
              local tmp_vc="$STAGE/VideoCore.card.$$.$RANDOM"
              cp -f "$vc_card" "$tmp_vc"
              write_uaem_with_perms "----rwed" "$tmp_vc"
              ensure_img_dir "$sys_root/Libs/Picasso96"
              fs_copy_file "$tmp_vc" "$sys_root/Libs/Picasso96/VideoCore.card" --uaemetadata UaeMetafile
              need_vc=0
            fi
          fi
          if [[ "$need_uae" -eq 1 ]]; then
            local uae_card
            uae_card="$(find "$tmp_p96" -iname "Uaegfx.card" 2>/dev/null | head -n1)"
            if [[ -n "$uae_card" ]]; then
              echo "==> P96: found Uaegfx.card in Aminet shareware"
              local tmp_uae="$STAGE/Uaegfx.card.$$.$RANDOM"
              cp -f "$uae_card" "$tmp_uae"
              write_uaem_with_perms "----rwed" "$tmp_uae"
              ensure_img_dir "$sys_root/Libs/Picasso96"
              fs_copy_file "$tmp_uae" "$sys_root/Libs/Picasso96/Uaegfx.card" --uaemetadata UaeMetafile
              need_uae=0
            fi
          fi
        fi

        rm -rf "$tmp_p96" 2>/dev/null || true
      fi
    fi
  fi

  if [[ "$need_vc" -eq 1 || "$need_uae" -eq 1 ]]; then
    echo "==> WARN: P96 drivers missing: VideoCore.card=${need_vc} Uaegfx.card=${need_uae}"
    echo "==> Place VideoCore.card/Uaegfx.card in Assets/LocalAmigaPackages/P96Drivers"
  fi
}

# install_ibrowse_from_official()
#   Install IBrowse and AmiSSL from official packages (ibrowse-dev.net).
#   $1 = sys_root
#   $2 = ibrowse_dir (directory containing downloaded .lha files)
#   $3 = key_file (optional IBrowse registration key)
#   $4 = js_fpu_variant: reserved for future use (not applied — spatch is Amiga-only)
install_ibrowse_from_official() {
  local sys_root="$1"
  local ibrowse_dir="$2"
  local key_file="${3:-}"
  local js_fpu_variant="${4:-040fpu}"  # reserved; see javascript.library install block

  echo "==> Installing IBrowse/AmiSSL from official packages"

  # Install IBrowse if present
  local ibrowse_lha=""
  for f in "$ibrowse_dir"/IBrowse*.lha; do
    [[ -f "$f" ]] && ibrowse_lha="$f" && break
  done

  local ibrowse_installed=0
  if [[ -n "$ibrowse_lha" ]]; then
    echo "  -> Installing IBrowse from: $(basename "$ibrowse_lha")"
    local tmp_ib="$STAGE/ibrowse-extract"
    rm -rf "$tmp_ib" 2>/dev/null || true
    mkdir -p "$tmp_ib"

    "$HST_IMAGER" fs extract "$ibrowse_lha" "$tmp_ib" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$ibrowse_lha" "$tmp_ib" --recursive 2>/dev/null || {
      echo "  -> WARN: Failed to extract IBrowse"
    }

    # Debug: show extracted structure
    echo "  -> DEBUG: IBrowse archive contents:"
    find "$tmp_ib" -maxdepth 3 -type d 2>/dev/null | head -20 | while read d; do echo "     DIR: $d"; done
    find "$tmp_ib" -maxdepth 3 -type f -name "*.codec" 2>/dev/null | head -10 | while read f; do echo "     CODEC: $f"; done
    find "$tmp_ib" -maxdepth 3 -type f -name "spatch" 2>/dev/null | while read f; do echo "     SPATCH: $f"; done

    # Find IBrowse root (usually IBrowse3.0-OS3/)
    local ib_root=""
    ib_root="$(find "$tmp_ib" -maxdepth 1 -type d -name "IBrowse*" | head -n1)"
    if [[ -n "$ib_root" && -d "$ib_root" ]]; then
      echo "  -> Found IBrowse root: $(basename "$ib_root")"

      # Copy TransferAnimation from Anims/ to Images/ on host BEFORE copying to image.
      # IBrowse defaults look for TransferAnimation in PROGDIR:Images/ but the
      # archive ships it in a separate Anims/ directory.
      local ib_images_dir=""
      ib_images_dir="$(find "$ib_root" -maxdepth 1 -type d -iname "images" 2>/dev/null | head -n1)"
      local ib_anims_dir=""
      ib_anims_dir="$(find "$ib_root" -maxdepth 1 -type d -iname "anims" 2>/dev/null | head -n1)"
      if [[ -n "$ib_anims_dir" && -d "$ib_anims_dir" && -n "$ib_images_dir" && -d "$ib_images_dir" ]]; then
        local anim_file
        for anim_file in "$ib_anims_dir"/*; do
          [[ -f "$anim_file" ]] || continue
          local anim_name
          anim_name="$(basename "$anim_file")"
          if [[ ! -f "$ib_images_dir/$anim_name" ]]; then
            cp "$anim_file" "$ib_images_dir/$anim_name" 2>/dev/null || true
            echo "  -> Copied $anim_name from Anims/ to Images/ for default path"
          fi
        done
      fi

      # Remove installer scripts before copying — IBrowse is already installed at
      # build time; Install-IBrowse.info (DefaultTool=Installer) causes a Workbench
      # "Unable to open Installer" dialog when clicked because Installer is not on image.
      find "$ib_root" -maxdepth 1 -iname "Install*" -delete 2>/dev/null || true

      # Install to Programs/Ibrowse (lowercase 'b' for compatibility with existing scripts)
      ensure_img_dir "$sys_root/Programs/Ibrowse"
      fs_copy_tree_filtered "$ib_root" "$sys_root/Programs/Ibrowse"

      # Copy IBrowse.info from Icons/ subdirectory to make the binary visible on Workbench
      # The official archive stores icons separately in Icons/ directory
      if [[ -f "$ib_root/Icons/IBrowse.info" ]]; then
        echo "  -> Copying IBrowse.info from Icons/ to make binary visible"
        fs_copy_file "$ib_root/Icons/IBrowse.info" "$sys_root/Programs/Ibrowse/IBrowse.info"
      fi

      # Copy drawer icon for Programs/Ibrowse
      local found_drawer_info=0
      # First try IBrowseDrw.info from Icons/ directory
      if [[ -f "$ib_root/Icons/IBrowseDrw.info" ]]; then
        fs_copy_file "$ib_root/Icons/IBrowseDrw.info" "$sys_root/Programs/Ibrowse.info"
        echo "  -> Copied drawer icon from Icons/IBrowseDrw.info"
        found_drawer_info=1
      fi

      # Fallback: check for drawer icon at archive root level
      if [[ "$found_drawer_info" -eq 0 ]]; then
        for info_name in "IBrowse3.0-OS3.info" "IBrowse.info" "ibrowse.info"; do
          if [[ -f "$tmp_ib/$info_name" ]]; then
            fs_copy_file "$tmp_ib/$info_name" "$sys_root/Programs/Ibrowse.info"
            echo "  -> Copied drawer icon: $info_name"
            found_drawer_info=1
            break
          fi
        done
      fi

      # Install spatch to C: - check multiple locations (case-insensitive)
      local spatch_path=""
      spatch_path="$(find "$ib_root" -maxdepth 2 -type f -iname "spatch" 2>/dev/null | head -n1)"
      if [[ -n "$spatch_path" && -f "$spatch_path" ]]; then
        echo "  -> Installing spatch to C: (from $(basename "$(dirname "$spatch_path")")/)"
        fs_copy_file "$spatch_path" "$sys_root/C/spatch"
      else
        echo "  -> WARN: spatch not found in IBrowse archive"
      fi

      # Explicitly copy codecs one-by-one as insurance against hst-imager's
      # --recursive --quiet silently dropping files during fs_copy_tree_filtered.
      # Observed: only gif.codec copied; jpeg/png/xbm dropped despite being present.
      local codec_dir=""
      codec_dir="$(find "$ib_root" -maxdepth 1 -type d -iname "codecs" 2>/dev/null | head -n1)"
      if [[ -n "$codec_dir" && -d "$codec_dir" ]]; then
        ensure_img_dir "$sys_root/Programs/Ibrowse/codecs"
        local codec_count=0
        for codec_file in "$codec_dir"/*; do
          [[ -f "$codec_file" ]] || continue
          local codec_name
          codec_name="$(basename "$codec_file")"
          fs_copy_file "$codec_file" "$sys_root/Programs/Ibrowse/codecs/$codec_name"
          codec_count=$((codec_count + 1))
        done
        echo "  -> Explicitly copied $codec_count file(s) from codecs/ to Programs/Ibrowse/codecs/"

        # Also copy all fpu-variant spatch directories explicitly
        for spatch_variant in codecs020fpu codecs040fpu codecs060fpu; do
          local sv_dir=""
          sv_dir="$(find "$ib_root" -maxdepth 1 -type d -iname "$spatch_variant" 2>/dev/null | head -n1)"
          if [[ -n "$sv_dir" && -d "$sv_dir" ]]; then
            ensure_img_dir "$sys_root/Programs/Ibrowse/$spatch_variant"
            local sv_count=0
            for sv_file in "$sv_dir"/*; do
              [[ -f "$sv_file" ]] || continue
              local sv_name
              sv_name="$(basename "$sv_file")"
              fs_copy_file "$sv_file" "$sys_root/Programs/Ibrowse/$spatch_variant/$sv_name"
              sv_count=$((sv_count + 1))
            done
            echo "  -> Explicitly copied $sv_count file(s) from $spatch_variant/"
          fi
        done
      else
        echo "  -> WARN: No codecs directory found in IBrowse archive"
      fi

      # Install IBrowse MUI custom classes to MUI:Libs/mui/ (Programs/MUI/Libs/mui/)
      # IBrowse ships NList.mcc, NListtree.mcc, NBitmap.mcc, etc. in its own mui/ dir
      # but MUI only searches LIBS:MUI/ for custom classes (= MUI:Libs/mui/ via assigns).
      local ib_mui_dir=""
      ib_mui_dir="$(find "$ib_root" -maxdepth 1 -type d -iname "mui" 2>/dev/null | head -n1)"
      if [[ -n "$ib_mui_dir" && -d "$ib_mui_dir" ]]; then
        ensure_img_dir "$sys_root/Programs/MUI/Libs/mui"
        local mcc_count=0
        for mcc_file in "$ib_mui_dir"/*.mcc "$ib_mui_dir"/*.mcp; do
          [[ -f "$mcc_file" ]] || continue
          local mcc_name
          mcc_name="$(basename "$mcc_file")"
          fs_copy_file "$mcc_file" "$sys_root/Programs/MUI/Libs/mui/$mcc_name"
          mcc_count=$((mcc_count + 1))
        done
        echo "  -> Installed $mcc_count IBrowse MUI classes to MUI:Libs/mui/"
      fi

      # Install javascript.library to Programs/Ibrowse/ (IBrowse's PROGDIR:).
      # IBrowse opens it via OpenLibrary("PROGDIR:javascript.library", ...) so
      # it must live in the IBrowse program directory, NOT in SYS:Libs/.
      # IBrowse 3.0+ ships javascript/javascript.library.020 (215 KB complete
      # binary) plus spatch delta files (javascript.library.020-040fpu etc.,
      # ~53 KB). The deltas require the Amiga spatch tool to apply — we cannot
      # run it on macOS, so always use the base .020 binary directly. It is a
      # complete, working 68020+ library; the FPU variants only add floating-
      # point optimisations and are not required for correct operation.
      local js_dir=""
      js_dir="$(find "$ib_root" -maxdepth 2 -type d -iname "javascript" 2>/dev/null | head -n1)"
      if [[ -n "$js_dir" && -d "$js_dir" && -f "$js_dir/javascript.library.020" ]]; then
        ensure_img_dir "$sys_root/Programs/Ibrowse"
        fs_copy_file "$js_dir/javascript.library.020" "$sys_root/Programs/Ibrowse/javascript.library"
        echo "  -> Installed javascript.library.020 to Programs/Ibrowse/ (PROGDIR:)"
      else
        # Fall back: plain javascript.library if present (older archive layout)
        local js_lib=""
        js_lib="$(find "$tmp_ib" -type f -iname "javascript.library" ! -name "*.020*" 2>/dev/null | head -n1)"
        if [[ -n "$js_lib" && -f "$js_lib" ]]; then
          ensure_img_dir "$sys_root/Programs/Ibrowse"
          fs_copy_file "$js_lib" "$sys_root/Programs/Ibrowse/javascript.library"
          echo "  -> Installed $(basename "$js_lib") to Programs/Ibrowse/ (PROGDIR:)"
        else
          echo "  -> javascript.library not in archive (IBrowse 3.0+ has JS built-in)"
        fi
      fi

      # Verify Images/ directory was copied (TransferAnimation, StatusBar, etc.)
      # IBrowse uses PROGDIR:Images/ for toolbar and status bar graphics.
      # (Anims/ → Images/ copy already done above, before fs_copy_tree_filtered)
      if [[ -n "${ib_images_dir:-}" && -d "${ib_images_dir:-}" ]]; then
        local img_count
        img_count="$(find "$ib_images_dir" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')"
        echo "  -> Found $img_count image files in: $(basename "$ib_images_dir")/"
      else
        echo "  -> WARN: No Images/ directory found in IBrowse archive"
      fi

      # Install IBrowse.prefs — priority: archive > bundled assets > skip.
      # IBrowse stores its bookmarks (FTLK chunk) and UI settings in IBrowse.prefs,
      # NOT in ibrowse-hotlist.html. Without a prefs file, IBrowse starts with an
      # empty in-memory hotlist and the user sees no bookmarks.
      # The bundled prefs uses PROGDIR: paths for images/cache which are correct
      # regardless of where IBrowse is installed.
      local _ib_prefs_installed=0
      if [[ -f "$ib_root/IBrowse.prefs" ]]; then
        echo "  -> Archive includes IBrowse.prefs — keeping archive version"
        _ib_prefs_installed=1
      fi
      if [[ "$_ib_prefs_installed" -eq 0 ]]; then
        local _bundled_prefs="$ASSETS_DIR/AmigaFiles/System/Programs/IBrowse/IBrowse.prefs"
        if [[ -f "$_bundled_prefs" ]]; then
          fs_copy_file "$_bundled_prefs" "$sys_root/Programs/Ibrowse/IBrowse.prefs"
          echo "  -> Installed bundled IBrowse.prefs (with bookmarks)"
          _ib_prefs_installed=1
        fi
      fi
      if [[ "$_ib_prefs_installed" -eq 0 ]]; then
        echo "  -> No IBrowse.prefs — IBrowse will create defaults on first launch (no bookmarks)"
      fi

      # Ensure cache directory exists at build time
      ensure_img_dir "$sys_root/Programs/Ibrowse/cache"

      # Write ibrowse-hotlist.html (V2 HTML export format — secondary to FTLK in prefs)
      local _ib_hotlist="$STAGE/ibrowse-hotlist.html"
      cat > "$_ib_hotlist" << 'IBHL_EOF'
<!-- IBrowse Hotlist V2 -->
<HTML>
<BODY>
<H1>IBrowse Hotlist</H1>
<H3 IBHLFLAGS="NOMENU" ><B>Amiga</B><UL>
<LI><A HREF="http://www.amiga-news.de/" IBSHORTCUT="">amiga-news.de</A>
<LI><A HREF="https://www.amigaworld.de" IBSHORTCUT="">AmigaWorld.de</A>
<LI><A HREF="https://amigaworld.net/" IBSHORTCUT="">Amigaworld.net</A>
<LI><A HREF="https://forum.amiga.org/" IBSHORTCUT="">Amiga.org</A>
<LI><A HREF="https://eab.abime.net/" IBSHORTCUT="">EAB</A>
<LI><A HREF="https://www.amigans.net/" IBSHORTCUT="">Amigans.net</A>
<LI><A HREF="http://www.sasg.com/" IBSHORTCUT="">SASG</A>
<LI><A HREF="https://www.a1k.org/" IBSHORTCUT="">a1k.org</A>
</UL>
</BODY>
</HTML>
IBHL_EOF
      if [[ -s "$_ib_hotlist" ]]; then
        fs_copy_file "$_ib_hotlist" "$sys_root/Programs/Ibrowse/ibrowse-hotlist.html"
        echo "  -> Written IBrowse hotlist (AmigaWorld.de)"
      fi

      ibrowse_installed=1
      echo "  -> IBrowse installed to Programs/Ibrowse"
    else
      echo "  -> WARN: Could not find IBrowse root directory in archive"
      echo "  -> DEBUG: Top-level contents:"
      ls -la "$tmp_ib" 2>/dev/null | head -10
    fi

    rm -rf "$tmp_ib" 2>/dev/null || true
  fi

  # Install AmiSSL if present
  local amissl_lha=""
  for f in "$ibrowse_dir"/AmiSSL*.lha; do
    [[ -f "$f" ]] && amissl_lha="$f" && break
  done

  if [[ -n "$amissl_lha" ]]; then
    echo "  -> Installing AmiSSL from: $(basename "$amissl_lha")"
    local tmp_ssl="$STAGE/amissl-extract"
    rm -rf "$tmp_ssl" 2>/dev/null || true
    mkdir -p "$tmp_ssl"

    "$HST_IMAGER" fs extract "$amissl_lha" "$tmp_ssl" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$amissl_lha" "$tmp_ssl" --recursive 2>/dev/null || {
      echo "  -> WARN: Failed to extract AmiSSL"
    }

    # Install AmiSSL components to Programs/AmiSSL/ (matching Windows reference layout).
    # The AmiSSL: assign (User-Startup) points here; AmiSSL uses AmiSSL:Certs/ for CA
    # certificates and AmiSSL:Libs/ is added to LIBS: for library resolution.
    local ssl_root=""
    ssl_root="$(find "$tmp_ssl" -maxdepth 1 -type d -name "AmiSSL*" | head -n1)"
    if [[ -n "$ssl_root" && -d "$ssl_root" ]]; then
      local amissl_dest="$sys_root/Programs/AmiSSL"

      # Install amisslmaster.library to both Programs/AmiSSL/Libs/ and SYS:Libs/
      ensure_img_dir "$amissl_dest/Libs"
      ensure_img_dir "$sys_root/Libs"
      if [[ -f "$ssl_root/Libs/AmigaOS3/amisslmaster.library" ]]; then
        fs_copy_file "$ssl_root/Libs/AmigaOS3/amisslmaster.library" "$amissl_dest/Libs/amisslmaster.library"
        fs_copy_file "$ssl_root/Libs/AmigaOS3/amisslmaster.library" "$sys_root/Libs/amisslmaster.library"
      fi

      # Install versioned AmiSSL libraries to Programs/AmiSSL/Libs/AmiSSL/
      # AND to SYS:Libs/AmiSSL/ so amisslmaster.library can find them via
      # LIBS:AmiSSL/ from the base SYS:Libs path without relying on ADD assigns.
      ensure_img_dir "$amissl_dest/Libs/AmiSSL"
      ensure_img_dir "$sys_root/Libs/AmiSSL"
      if [[ -d "$ssl_root/Libs/AmigaOS3/AmiSSL" ]]; then
        # Prefer 68020-40 version for Emu68
        if [[ -d "$ssl_root/Libs/AmigaOS3/AmiSSL/68020-40" ]]; then
          for lib in "$ssl_root/Libs/AmigaOS3/AmiSSL/68020-40"/*.library; do
            [[ -f "$lib" ]] || continue
            fs_copy_file "$lib" "$amissl_dest/Libs/AmiSSL/$(basename "$lib")"
            fs_copy_file "$lib" "$sys_root/Libs/AmiSSL/$(basename "$lib")"
          done
        fi
      fi

      # Warn only if no versioned library (amissl_v*.library) is present at all.
      # AmiSSL 5.x uses amisslmaster to dispatch to the versioned lib; the name
      # format changed across releases (amissl_v5.library → amissl_v361.library in
      # 5.26, amissl_v362.library in 5.27). Any amissl_v*.library is acceptable;
      # amisslmaster knows what to open.
      local _has_vlib=0
      for _vlib in "$ssl_root/Libs/AmigaOS3/AmiSSL/68020-40"/amissl_v*.library; do
        [[ -f "$_vlib" ]] && _has_vlib=1 && break
      done
      if [[ $_has_vlib -eq 0 && -f "$ssl_root/Libs/AmigaOS3/amisslmaster.library" ]]; then
        echo ""
        echo "  !! WARNING: AmiSSL archive has amisslmaster.library but no versioned"
        echo "  !! amissl_v*.library — AmiSSL will not function. Use a complete archive:"
        echo "  !!   https://github.com/jens-maus/amissl/releases"
        echo ""
      fi

      # Install CA certificates to Programs/AmiSSL/Certs/
      if [[ -d "$ssl_root/Certs" ]]; then
        fs_copy_tree_filtered "$ssl_root/Certs" "$amissl_dest/Certs"
      fi

      # Install OpenSSL command to C:
      ensure_img_dir "$sys_root/C"
      if [[ -f "$ssl_root/C/AmigaOS3/OpenSSL" ]]; then
        fs_copy_file "$ssl_root/C/AmigaOS3/OpenSSL" "$sys_root/C/OpenSSL"
      fi

      echo "  -> AmiSSL installed to Programs/AmiSSL/"

      # Install drawer icon (AmiSSL.info at archive root) as Programs/AmiSSL.info
      local _ssl_info=""
      for _inf in "$tmp_ssl"/AmiSSL.info "$tmp_ssl"/amissl.info; do
        [[ -f "$_inf" ]] && _ssl_info="$_inf" && break
      done
      [[ -n "$_ssl_info" ]] && fs_copy_file "$_ssl_info" "$sys_root/Programs/AmiSSL.info"
    fi

    rm -rf "$tmp_ssl" 2>/dev/null || true
  fi

  install_ibrowse_key_file "$sys_root" "$key_file" "$ibrowse_dir"

  # Create OneTimeRun/Ibrowse script for codec patching (040fpu for Emu68)
  if [[ "$ibrowse_installed" -eq 1 ]]; then
    local tmp_ib_script="$STAGE/Ibrowse.firstboot"
    cat > "$tmp_ib_script" <<'__IBROWSE_PATCH__'
; Amiga Imager: IBrowse codec patching for 040/FPU (Emu68)
; Installed from official ibrowse-dev.net
FAILAT 21

; Ensure Programs drawer has an icon (visible in Workbench)
IF NOT EXISTS SYS:Programs.info
  IF EXISTS SYS:Prefs/Env-Archive/Sys/def_drawer.info
    COPY SYS:Prefs/Env-Archive/Sys/def_drawer.info SYS:Programs.info CLONE QUIET
  ELSE
    IF EXISTS ENV:Sys/def_drawer.info
      COPY ENV:Sys/def_drawer.info SYS:Programs.info CLONE QUIET
    ENDIF
  ENDIF
ENDIF

; Check prerequisites for codec patching
IF NOT EXISTS C:spatch
  ECHO "spatch not found, skipping codec patching"
  SKIP END
ENDIF

IF NOT EXISTS SYS:Programs/Ibrowse/codecs/gif.codec
  ECHO "IBrowse codecs not found, skipping"
  SKIP END
ENDIF

IF NOT EXISTS SYS:Programs/Ibrowse/codecs040fpu/gif.codec.spatch
  ECHO "040fpu patches not found, skipping"
  SKIP END
ENDIF

ECHO "Patching IBrowse codecs for 040/FPU..."

C:spatch >NIL: -oSYS:Programs/Ibrowse/codecs/gif.codec -pSYS:Programs/Ibrowse/codecs040fpu/gif.codec.spatch SYS:Programs/Ibrowse/codecs/gif.codec
C:spatch >NIL: -oSYS:Programs/Ibrowse/codecs/jpeg.codec -pSYS:Programs/Ibrowse/codecs040fpu/jpeg.codec.spatch SYS:Programs/Ibrowse/codecs/jpeg.codec
C:spatch >NIL: -oSYS:Programs/Ibrowse/codecs/png.codec -pSYS:Programs/Ibrowse/codecs040fpu/png.codec.spatch SYS:Programs/Ibrowse/codecs/png.codec
C:spatch >NIL: -oSYS:Programs/Ibrowse/codecs/xbm.codec -pSYS:Programs/Ibrowse/codecs040fpu/xbm.codec.spatch SYS:Programs/Ibrowse/codecs/xbm.codec

IF EXISTS SYS:Programs/Ibrowse/codecs/imagedecode.module
  IF EXISTS SYS:Programs/Ibrowse/codecs040fpu/imagedecode.module.spatch
    C:spatch >NIL: -oSYS:Programs/Ibrowse/codecs/imagedecode.module -pSYS:Programs/Ibrowse/codecs040fpu/imagedecode.module.spatch SYS:Programs/Ibrowse/codecs/imagedecode.module
  ENDIF
ENDIF

; Clean up patch files
DELETE SYS:Programs/Ibrowse/codecs020fpu ALL FORCE QUIET
DELETE SYS:Programs/Ibrowse/codecs040fpu ALL FORCE QUIET
DELETE SYS:Programs/Ibrowse/codecs060fpu ALL FORCE QUIET

; Ensure cache directory exists
MAKEDIR >NIL: SYS:Programs/Ibrowse/cache

ECHO "IBrowse codec patching complete."

LAB END
__IBROWSE_PATCH__
    make_lf_only "$tmp_ib_script"
    chmod +x "$tmp_ib_script"  # Amiga 's' bit: required for Execute in T:RunTimeScript
    ensure_img_dir "$sys_root/S/OneTimeRun"
    fs_copy_file "$tmp_ib_script" "$sys_root/S/OneTimeRun/Ibrowse"
    echo "  -> Created OneTimeRun/Ibrowse script for codec patching"

    # Verify installation on image
    echo "  -> DEBUG: Verifying IBrowse installation on image:"
    echo "     Programs/Ibrowse contents:"
    diag_fs_dir "$sys_root/Programs/Ibrowse"
    echo "     Programs/Ibrowse/Images contents:"
    diag_fs_dir "$sys_root/Programs/Ibrowse/Images"
    echo "     Programs/Ibrowse/codecs contents:"
    diag_fs_dir "$sys_root/Programs/Ibrowse/codecs"
    echo "     Programs/Ibrowse/codecs040fpu contents:"
    diag_fs_dir "$sys_root/Programs/Ibrowse/codecs040fpu"
    echo "     Programs/Ibrowse/javascript.library:"
    diag_fs_dir "$sys_root/Programs/Ibrowse/javascript.library"
    echo "     C/spatch check:"
    diag_fs_dir "$sys_root/C/spatch"
  fi
}

# _wbdock_dock_entries()
#   Print one dock entry path (SYS:...) per line for every package that
#   declares one. Plan-driven: collects "dock-entry:<path>" post-install
#   actions from the loaded build plan (PackageInstallDescriptor.dockEntry;
#   dock items the user untoggled never reach the plan). Fallback for
#   plan-less manual runs: static id->path table gated on package selection.
#   AmigaAMP's binary name varies per archive ("AmigaAMP" vs "AmigaAMP3");
#   install_pkg_amigaamp exposes the real name via _AMIGAAMP_BIN_NAME.
_wbdock_dock_entries() {
  if [[ "${_BPLAN_LOADED:-0}" -eq 1 ]]; then
    local i actions a atype aarg
    local _wda=()
    for ((i = 0; i < ${#_BPLAN_IDS[@]}; i++)); do
      actions="${_BPLAN_ACTIONS[$i]:--}"
      [[ "$actions" == "-" || -z "$actions" ]] && continue
      IFS='|' read -r -a _wda <<< "$actions"
      for a in "${_wda[@]}"; do
        atype="${a%%:*}"
        [[ "$atype" == "dock-entry" ]] || continue
        aarg="${a#*:}"
        if [[ "${_BPLAN_IDS[$i]}" == "amigaamp" && -n "${_AMIGAAMP_BIN_NAME:-}" ]]; then
          aarg="SYS:Programs/AmigaAMP/${_AMIGAAMP_BIN_NAME}"
        fi
        printf '%s\n' "$aarg"
      done
    done
    return 0
  fi
  # Legacy fallback (no --build-plan): known dockable packages
  local pkg
  for pkg in igame lumiftp amigaamp riva scummvm; do
    package_is_selected "$pkg" || continue
    case "$pkg" in
      igame)    printf '%s\n' "SYS:Programs/iGame/iGame" ;;
      lumiftp)  printf '%s\n' "SYS:Programs/LumiFTP/LumiFTP" ;;
      amigaamp) printf '%s\n' "SYS:Programs/AmigaAMP/${_AMIGAAMP_BIN_NAME:-AmigaAMP}" ;;
      riva)     printf '%s\n' "SYS:Programs/RiVA" ;;
      scummvm)  printf '%s\n' "SYS:Programs/ScummVM/ScummVM" ;;
    esac
  done
}

# install_wbdock()
#   Extract and install WBDock2 dock launcher into Programs/WBDock.
#   Creates a delayed launcher script (S/StartWBDock) that waits for IPrefs
#   to switch to RTG before starting WBDock.  The launcher is invoked from
#   the Startup-Sequence by relocate_iprefs_after_loadwb().
#   $1 = sys_root   $2 = ibrowse_dir (staging dir containing wbdock2.lha)

# ===========================================================================
# WBDock, NewMeter configuration
# ===========================================================================
install_wbdock() {
  local sys_root="$1"
  local ibrowse_dir="$2"

  echo "==> Installing WBDock2"

  local wbdock_lha=""
  for f in "$ibrowse_dir"/wbdock2.lha "$ibrowse_dir"/WBDock2.lha; do
    [[ -f "$f" ]] && wbdock_lha="$f" && break
  done

  if [[ -z "$wbdock_lha" ]]; then
    echo "==> WARN: WBDock2 LHA not found in staging dir"
    return 0
  fi

  local tmp_wd="$STAGE/wbdock-extract"
  rm -rf "$tmp_wd" 2>/dev/null || true
  mkdir -p "$tmp_wd"

  # Try lha first (hst-imager has IndexOutOfRangeException on some LHA formats).
  # Suppress lhasa stdout/stderr: it prints "Failed to create parent directory" for
  # Latin-1 locale dirs (français, türkçe) that APFS rejects due to the ? substitution.
  # Those locale files are cosmetic; the rest of WBDock2 extracts correctly.
  local _wd_extracted=0
  local _lha_bin=""
  _lha_bin="$(find_lha)" || true
  if [[ -n "$_lha_bin" ]]; then
    (cd "$tmp_wd" && "$_lha_bin" x "$wbdock_lha" >/dev/null 2>&1) || true
    if [[ -n "$(find "$tmp_wd" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
      _wd_extracted=1
    fi
  fi
  if (( _wd_extracted == 0 )); then
    "$HST_IMAGER" fs extract "$wbdock_lha" "$tmp_wd" --recursive --force 2>/dev/null || \
    "$HST_IMAGER" fs extract "$wbdock_lha" "$tmp_wd" --recursive 2>/dev/null || {
      echo "==> WARN: Failed to extract WBDock2"
      return 0
    }
  fi

  # Find WBDock2 root directory
  local wd_root=""
  wd_root="$(find "$tmp_wd" -maxdepth 1 -type d -name "WBDock2" | head -n1)"
  if [[ -z "$wd_root" || ! -d "$wd_root" ]]; then
    echo "==> WARN: WBDock2 directory not found in archive"
    return 0
  fi

  # Install to Programs/WBDock/ (NOT WBStartup — avoids race with IPrefs RTG switch).
  # A delayed launcher script (S/StartWBDock) is created below and invoked from
  # the Startup-Sequence after IPrefs, giving IPrefs time to switch to RTG first.
  local wd_dst="$sys_root/Programs/WBDock"
  ensure_img_dir "$wd_dst"
  fs_copy_tree_filtered "$wd_root" "$wd_dst"

  # Install drawer icon (WBDock2.info at archive root) as Programs/WBDock.info
  local _wd_info=""
  for _inf in "$tmp_wd"/WBDock2.info "$tmp_wd"/wbdock2.info; do
    [[ -f "$_inf" ]] && _wd_info="$_inf" && break
  done
  [[ -n "$_wd_info" ]] && fs_copy_file "$_wd_info" "$sys_root/Programs/WBDock.info"

  # Copy apps directory (dock plugins) — WBDock looks in PROGDIR:apps/
  if [[ -d "$wd_root/apps" ]]; then
    ensure_img_dir "$wd_dst/apps"
    fs_copy_tree_filtered "$wd_root/apps" "$wd_dst/apps"
  fi

  # Copy Skins directory — WBDock looks in PROGDIR:Skins/
  if [[ -d "$wd_root/Skins" ]]; then
    ensure_img_dir "$wd_dst/Skins"
    fs_copy_tree_filtered "$wd_root/Skins" "$wd_dst/Skins"
  fi

  # Write custom WBDock.config (one Amiga path per line, matching archive format).
  # Must use exact filename "WBDock.config" to match what the archive ships.
  local cfg_host="$STAGE/WBDock.config"
  cat > "$cfg_host" <<'WBDOCKCFG'
SYS:System/Shell
SYS:Tools/HDToolBox
SYS:Prefs/ScreenMode
SYS:Programs/Ibrowse/IBrowse
SYS:Programs/DOpus/DirectoryOpus
SYS:Programs/IMP3
WBDOCKCFG
  # Dynamic dock entries — declared per package in the build plan (or the
  # legacy fallback table). Entries whose target is missing from the image
  # (failed install, CPU-gated install) are skipped.
  local _dock_entry
  while IFS= read -r _dock_entry; do
    [[ -z "$_dock_entry" ]] && continue
    if fs_dir "$sys_root/${_dock_entry#SYS:}"; then
      echo "$_dock_entry" >> "$cfg_host"
    else
      echo "==> WARN: WBDock entry skipped (not present in image): $_dock_entry"
    fi
  done < <(_wbdock_dock_entries)
  # Add platform-specific network entry.
  # Platform scripts can override the default detection with an explicit dock
  # launcher path when they provide their own network helper script.
  if [[ -n "${WBDOCK_NETWORK_ENTRY:-}" ]]; then
    echo "${WBDOCK_NETWORK_ENTRY}" >> "$cfg_host"
  elif [[ -n "${HAS_FLOPPY+x}" ]]; then
    # PiStorm: WiFi online script
    echo "SYS:PiStorm/OnlineWifi" >> "$cfg_host"
  elif [[ -n "${NETWORK_CARD:-}" && "${NETWORK_CARD}" != "none" ]]; then
    # Classic with network card: Roadshow Go Online script
    echo "SYS:S/GoOnline" >> "$cfg_host"
  fi
  make_lf_only "$cfg_host"
  fs_copy_file "$cfg_host" "$wd_dst/WBDock.config"

  # Create delayed launcher script: waits 3s for IPrefs to apply RTG, then starts WBDock.
  # Invoked from Startup-Sequence via: Run >NIL: Execute SYS:S/StartWBDock
  local launcher="$STAGE/StartWBDock"
  cat > "$launcher" <<'__STARTWBDOCK__'
; Amiga Imager: delayed WBDock launcher
; Wait for IPrefs to switch to RTG screen mode before opening dock
Wait 3
; CD to WBDock directory so relative paths (config, patterns, textures) resolve
CD SYS:Programs/WBDock
Run <>NIL: WBDock
__STARTWBDOCK__
  make_lf_only "$launcher"
  chmod +x "$launcher"  # Amiga 's' bit: StartWBDock is an AmigaDOS script
  ensure_img_dir "$sys_root/S"
  fs_copy_file "$launcher" "$sys_root/S/StartWBDock"

  # Set Tool Manager display style via tooltype on WBDock.info.
  # WBDock reads PROGDIR:<name>.info for settings when launched from CLI too.
  local wd_info="$STAGE/WBDock.info"
  if fs_pull_file "$wd_dst/WBDock.info" "$wd_info" >/dev/null 2>&1; then
    modify_tooltype "$wd_info" "STYLE" "TOOLMANAGER"
    fs_copy_file "$wd_info" "$wd_dst/WBDock.info"
    rm -f "$wd_info" 2>/dev/null || true
  else
    echo "==> WARN: WBDock.info not found — cannot set STYLE=TOOLMANAGER"
  fi

  echo "==> WBDock2 installed to Programs/WBDock (STYLE=TOOLMANAGER, delayed launch via S/StartWBDock)"
}

# configure_newmeter_position()
#   Set NewMeter defaults: DELAY so it opens after IPrefs switches to RTG,
#   colours, behaviour, and optionally PiStorm hardware sensor ENVGAUGE lines.
#   Without DELAY, XPOS=-8 (right edge) lands in the centre of the larger
#   RTG screen because NewMeter opens on the ~640px native screen first.
#   $1 = sys_root      (hst-imager virtual path to the System partition)
#   $2 = delay         (seconds, default 5; use 20 for slow 68000 systems)
#   $3 = add_sensors   (1 = add PiStorm AK ENVGAUGE lines, default 0)
configure_newmeter_position() {
  local sys_root="$1"
  local delay="${2:-5}"
  local add_sensors="${3:-0}"

  echo "==> Configuring NewMeter tooltypes (DELAY=$delay)"

  local tmp_info="$STAGE/NewMeter.info"
  local tmp_tt="$STAGE/NewMeter-tt.txt"

  # Pull NewMeter.info from WBStartup/Disabled (installed there by CSV)
  if ! fs_pull_file "$sys_root/WBStartup/Disabled/NewMeter.info" "$tmp_info" >/dev/null 2>&1; then
    if ! fs_pull_file "$sys_root/WBStartup/NewMeter.info" "$tmp_info" >/dev/null 2>&1; then
      echo "==> WARN: NewMeter.info not found — skipping"
      return 0
    fi
  fi

  if ! export_tooltypes "$tmp_info" "$tmp_tt"; then
    echo "==> WARN: Failed to export NewMeter tooltypes"
    rm -f "$tmp_info"; return 1
  fi

  # DELAY — dynamic value; set separately before the static defaults loop
  if grep -qi "^DELAY" "$tmp_tt" 2>/dev/null; then
    "$AMIGA_TOOLS" replace-line-prefix "$tmp_tt" 'DELAY' "DELAY=${delay}"
  else
    printf 'DELAY=%s\n' "$delay" >> "$tmp_tt"
  fi

  # Apply static defaults in one pass — replace existing line or append
  while IFS= read -r _tt_line; do
    [[ -z "$_tt_line" ]] && continue
    _tt_key="${_tt_line%%=*}"
    if grep -qi "^${_tt_key}" "$tmp_tt" 2>/dev/null; then
      "$AMIGA_TOOLS" replace-line-prefix "$tmp_tt" "${_tt_key}" "${_tt_line}"
    else
      printf '%s\n' "$_tt_line" >> "$tmp_tt"
    fi
  done <<'NEWMETER_DEFAULTS'
FONT=helvetica/11
SHINECOLOR=EEEEEE
SHADOWCOLOR=FFFFFF
TEXTCOLOR=000000
BLUECOLOR=000000
CPUCOLOR=800000
CHIPCOLOR=EE0000
FASTCOLOR=EE7700
RTGCOLOR=228000
DOSCOLOR=008800
ZZTEMPCOLOR=00AA00
SENSORCOLOR=FFFF00
VALUECOLOR=000000
NOVOL=RAM
UPDATE=3
TRANSPARENCY=40
BORDER=10
NEWMETER_DEFAULTS

  # PiStorm AK hardware sensor gauges (ENVGAUGE lines share the same key,
  # so remove any existing ones and append the four platform-specific entries)
  if [[ "$add_sensors" == "1" ]]; then
    grep -v "^ENVGAUGE=" "$tmp_tt" > "${tmp_tt}.tmp" 2>/dev/null && mv "${tmp_tt}.tmp" "$tmp_tt"
    printf 'ENVGAUGE=AK_5V_supply_V "5V Supply" 0 750 2 "V:" FFFF00\n'          >> "$tmp_tt"
    printf 'ENVGAUGE=AK_RTC_battery_percent "Battery" 0 100 0 "%%" FFFF00\n'    >> "$tmp_tt"
    printf 'ENVGAUGE=AK_Internal_temp_C "Int. Temp" -200 1250 1 "C:" FFFF00\n'  >> "$tmp_tt"
    printf 'ENVGAUGE=AK_CPU_temp_C "CPU Temp" -200 1250 1 "C:" FFFF00\n'        >> "$tmp_tt"
  fi

  if ! import_tooltypes "$tmp_info" "$tmp_tt"; then
    echo "==> WARN: Failed to import NewMeter tooltypes"
    rm -f "$tmp_info" "$tmp_tt"; return 1
  fi

  fs_copy_file "$tmp_info" "$sys_root/WBStartup/Disabled/NewMeter.info"
  echo "==> NewMeter tooltypes configured"
  rm -f "$tmp_info" "$tmp_tt" 2>/dev/null || true
}


# ===========================================================================
# Icon update helpers & disk icons
# ===========================================================================
# --- Disk icon files and icon positioning (Windows parity) --------------------

# hst_icon_update_file()
#   Use hst-amiga to update icon metadata on a temporary host-side .info copy.
#   Returns path to updated temp copy, or original file path on fallback.
#   $1 = src_info
#   $2 = icon_type     (numeric type; e.g. 1=disk)
#   $3 = current_x
#   $4 = current_y
#   $5 = drawer_x
#   $6 = drawer_y
#   $7 = drawer_width
#   $8 = drawer_height
hst_icon_update_file() {
  local src_info="$1"
  local icon_type="${2:-}"
  local current_x="${3:-}"
  local current_y="${4:-}"
  local drawer_x="${5:-}"
  local drawer_y="${6:-}"
  local drawer_width="${7:-}"
  local drawer_height="${8:-}"

  local tmp_info="$STAGE/icon-$(basename "$src_info").$$.$RANDOM.info"
  if ! cp -f "$src_info" "$tmp_info" 2>/dev/null; then
    echo "$src_info"
    return 0
  fi

  if hst_icon_update_in_place "$tmp_info" "$icon_type" "$current_x" "$current_y" "$drawer_x" "$drawer_y" "$drawer_width" "$drawer_height"; then
    echo "$tmp_info"
    return 0
  fi

  rm -f "$tmp_info" 2>/dev/null || true
  echo "$src_info"
}

write_uaem_with_perms() {
  # Create a UAE metafile (.uaem) sidecar containing Amiga protection bits.
  # Callers must pass --uaemetadata UaeMetafile to fs_copy_file so hst-imager reads this.
  # Format: "<protection_string> <timestamp>"
  local perms="$1"
  local file="$2"
  local ts
  ts="$(date '+%Y-%m-%d %H:%M:%S.00')"
  printf "%s %s \n" "$perms" "$ts" > "${file}.uaem"
}

# install_disk_icons()
#   Copy disk.info files for each volume (EMU68BOOT, DH0, DH1+).
#   Preferred source is the selected icon ADF(s); Assets is fallback only.
#   $1 = sys_root  (image path to SYS: / SDH0 root)
install_disk_icons() {
  local sys_root="$1"
  local icon_root=""
  local icon_source_label=""

  if [[ ${#SELECTED_ICONSET_ADFS[@]} -gt 0 ]]; then
    local icon_adf_root="$STAGE/iconset-icons"
    rm -rf "$icon_adf_root" >/dev/null 2>&1 || true
    mkdir -p "$icon_adf_root"
    for _icon_adf in "${SELECTED_ICONSET_ADFS[@]}"; do
      echo "==> Disk icons: extracting selected icon ADF: $(basename "$_icon_adf")"
      extract_adf_to_host "$_icon_adf" "$icon_adf_root" || true
    done
    if [[ -d "$icon_adf_root" ]]; then
      icon_root="$icon_adf_root"
      icon_source_label="icon ADF"
    fi
  fi

  if [[ -z "$icon_root" ]]; then
    local assets_icon_dir="$ASSETS_DIR/AmigaFiles/IconFiles"
    if [[ -d "$assets_icon_dir" ]]; then
      echo "==> WARN: Disk icons falling back to Assets/AmigaFiles/IconFiles (icon ADF source unavailable)"
      icon_root="$assets_icon_dir"
      icon_source_label="assets fallback"
    fi
  fi

  # If NO icon source available at all, create minimal disk icons for
  # EMU68BOOT and Work using def_drawer.info from the installed Workbench.
  # DH0 keeps whatever icon the OS install media provides.
  if [[ -z "$icon_root" ]]; then
    local def_drawer_tmp="$STAGE/def_drawer_disk.info"
    if fs_pull_file "$SYS_ROOT/Prefs/Env-Archive/Sys/def_drawer.info" "$def_drawer_tmp" 2>/dev/null; then
      icon_root="$STAGE/fallback-icons"
      mkdir -p "$icon_root/Emu68BootDisk" "$icon_root/WorkDisk"
      cp "$def_drawer_tmp" "$icon_root/Emu68BootDisk/disk.info"
      cp "$def_drawer_tmp" "$icon_root/WorkDisk/disk.info"
      icon_source_label="def_drawer fallback"
    else
      echo "==> Disk icons: no icon source available (skipping)"
      return 0
    fi
  fi

  echo "==> Installing disk icon files (source: $icon_source_label)"

  # Load per-disk geometry from DiskDefaults.csv (Bash 3.2 compatible: indexed arrays)
  local dd_devnames=() dd_ix=() dd_iy=() dd_dx=() dd_dy=() dd_dw=() dd_dh=()
  local dd_csv
  dd_csv="$(find_input_csv "DiskDefaults.CSV" || true)"
  if [[ -n "$dd_csv" ]]; then
    local _dd_idx=0
    while IFS='|' read -r _dd_dev _dd_x _dd_y _dd_dx _dd_dy _dd_dw _dd_dh; do
      [[ -z "$_dd_dev" ]] && continue
      dd_devnames[$_dd_idx]="$_dd_dev"
      dd_ix[$_dd_idx]="$_dd_x"
      dd_iy[$_dd_idx]="$_dd_y"
      dd_dx[$_dd_idx]="$_dd_dx"
      dd_dy[$_dd_idx]="$_dd_dy"
      dd_dw[$_dd_idx]="$_dd_dw"
      dd_dh[$_dd_idx]="$_dd_dh"
      _dd_idx=$((_dd_idx + 1))
    done < <(awk -F';' '
      function trim(s){ gsub(/\r/, "", s); sub(/^[ \t"]+/, "", s); sub(/[ \t"]+$/, "", s); return s }
      function norm(s){ s=tolower(trim(s)); gsub(/[^a-z0-9]/, "", s); return s }
      function get(k,   idx,val){ idx=h[k]; if (idx == 0) return ""; val=trim($(idx)); return val }
      NR==1{ for(i=1;i<=NF;i++) h[norm($i)] = i; next }
      {
        dev=get("devicename"); if (dev == "") next
        print dev "|" get("iconx") "|" get("icony") "|" get("drawerx") "|" get("drawery") "|" get("dwidth") "|" get("dheight")
      }
    ' "$dd_csv")
    echo "==> DiskDefaults: loaded ${#dd_devnames[@]} entries from $(basename "$dd_csv")"
  fi

  # lookup_disk_defaults <DeviceName> — sets dd_* result vars
  # Returns 0 if found, 1 if not (caller uses fallback values).
  local dd_result_ix dd_result_iy dd_result_dx dd_result_dy dd_result_dw dd_result_dh
  lookup_disk_defaults() {
    local target="$1"
    local _i
    for _i in $(seq 0 $((${#dd_devnames[@]} - 1))); do
      if [[ "${dd_devnames[$_i]}" == "$target" ]]; then
        dd_result_ix="${dd_ix[$_i]}"
        dd_result_iy="${dd_iy[$_i]}"
        dd_result_dx="${dd_dx[$_i]}"
        dd_result_dy="${dd_dy[$_i]}"
        dd_result_dw="${dd_dw[$_i]}"
        dd_result_dh="${dd_dh[$_i]}"
        return 0
      fi
    done
    return 1
  }

  if [[ "$APPLY_ICON_POSITIONS" == "1" ]]; then
    echo "==> Icon positioning: applying disk icon geometry via amiga-tools (DiskDefaults.csv)"
  else
    echo "==> Disk icons: applying default left-column positions"
  fi

  find_disk_icon_for_role() {
    local role="$1"
    local root="$2"
    local cand=""

    # Windows reference (IconSets CSV): GlowIcons ADF uses Disk.Info (root) for
    # System partition, and Prefs/Env-Archive/Sys/def_harddisk.info for Work and
    # EMU68BOOT partitions.  Standard 4-color icons use the same icon for all.
    # We search for def_harddisk.info first for non-System roles so that GlowIcons
    # automatically get the hard-drive icon while standard icons fall through to
    # the generic Disk.info at the ADF root.
    case "$role" in
      sd0)
        for cand in \
          "$root/SD0_Disk.info" \
          "$root/Emu68BootDisk/disk.info" \
          "$root/WorkDisk/disk.info" \
          "$root/SD0/Disk.info"
        do
          [[ -f "$cand" ]] && { echo "$cand"; return 0; }
        done
        # GlowIcons: use def_harddisk.info (hard drive icon, not system floppy)
        cand="$(find "$root" -type f -iname 'def_harddisk.info' 2>/dev/null | head -n1 || true)"
        [[ -n "$cand" && -f "$cand" ]] && { echo "$cand"; return 0; }
        # Wildcard search
        cand="$(find "$root" -type f \( -iname '*sd0*disk.info' -o -iname '*emu68*boot*disk.info' -o -iname '*boot*disk.info' \) 2>/dev/null | head -n1 || true)"
        [[ -n "$cand" && -f "$cand" ]] && { echo "$cand"; return 0; }
        # Final fallback: generic Disk.info from ADF root (same as System)
        [[ -f "$root/Disk.info" ]] && { echo "$root/Disk.info"; return 0; }
        ;;
      dh0)
        for cand in \
          "$root/DH0_Disk.info" \
          "$root/SystemDisk/disk.info" \
          "$root/DH0/Disk.info" \
          "$root/Disk.info"
        do
          [[ -f "$cand" ]] && { echo "$cand"; return 0; }
        done
        cand="$(find "$root" -type f \( -iname '*dh0*disk.info' -o -iname '*system*disk.info' \) 2>/dev/null | head -n1 || true)"
        ;;
      dh1)
        for cand in \
          "$root/DH1_Disk.info" \
          "$root/WorkDisk/disk.info" \
          "$root/DH1/Disk.info"
        do
          [[ -f "$cand" ]] && { echo "$cand"; return 0; }
        done
        # GlowIcons: use def_harddisk.info (hard drive icon, not system floppy)
        cand="$(find "$root" -type f -iname 'def_harddisk.info' 2>/dev/null | head -n1 || true)"
        [[ -n "$cand" && -f "$cand" ]] && { echo "$cand"; return 0; }
        # Wildcard search
        cand="$(find "$root" -type f \( -iname '*dh1*disk.info' -o -iname '*work*disk.info' \) 2>/dev/null | head -n1 || true)"
        [[ -n "$cand" && -f "$cand" ]] && { echo "$cand"; return 0; }
        # Final fallback: generic Disk.info from ADF root (same as System)
        [[ -f "$root/Disk.info" ]] && { echo "$root/Disk.info"; return 0; }
        ;;
    esac

    if [[ -n "$cand" && -f "$cand" ]]; then
      echo "$cand"
      return 0
    fi
    return 1
  }

  pull_system_harddisk_icon() {
    local role="$1"
    local dest="$STAGE/${role}-system-harddisk.info"

    if fs_pull_file "$sys_root/Prefs/Env-Archive/Sys/def_harddisk.info" "$dest" 2>/dev/null; then
      echo "$dest"
      return 0
    fi
    if fs_pull_file "$sys_root/Disk.info" "$dest" 2>/dev/null; then
      echo "$dest"
      return 0
    fi
    return 1
  }

  # Per-disk positioning from DiskDefaults.csv with hardcoded fallbacks
  local partition_root_base="${PARTITION_ROOT_BASE:-$IMG/mbr/2/rdb}"

  # DH0 (System) - first Amiga partition
  local dh0_ix=15 dh0_iy=4 dh0_dx=50 dh0_dy=50 dh0_dw=533 dh0_dh=160
  if [[ "$APPLY_ICON_POSITIONS" == "1" ]] && lookup_disk_defaults "SDH0"; then
    dh0_ix="$dd_result_ix"; dh0_iy="$dd_result_iy"
    dh0_dx="$dd_result_dx"; dh0_dy="$dd_result_dy"
    dh0_dw="$dd_result_dw"; dh0_dh="$dd_result_dh"
  fi
  local dh0_icon=""
  dh0_icon="$(find_disk_icon_for_role dh0 "$icon_root" || true)"
  if [[ -n "$dh0_icon" ]]; then
    local dh0_icon_to_copy
    dh0_icon_to_copy="$(hst_icon_update_file "$dh0_icon" "1" "$dh0_ix" "$dh0_iy" "$dh0_dx" "$dh0_dy" "$dh0_dw" "$dh0_dh")"
    fs_copy_file "$dh0_icon_to_copy" "$sys_root/Disk.info"
    echo "  -> ${PART_NAMES[0]} (System): $(basename "$dh0_icon") @ ${dh0_ix},${dh0_iy}"
  fi

  # DH1 (Work) - first work partition, placed above EMU68BOOT at its CSV position
  local work_icon=""
  if [[ -n "${MISTER_SHARED_FOLDER+x}" ]]; then
    work_icon="$(pull_system_harddisk_icon dh1 || true)"
  fi
  [[ -n "$work_icon" ]] || work_icon="$(find_disk_icon_for_role dh1 "$icon_root" || true)"
  local dh1_placed_y=150  # CSV Y for SDH1; used to anchor prev_extra_y below
  if [[ -n "$work_icon" && "${PART_BYTES[1]:-0}" -gt 0 && -n "${PART_PNS[1]:-}" ]]; then
    local dh1_root="$partition_root_base/${PART_PNS[1]}"
    if fs_dir "$dh1_root"; then
      local wk1_ix=15 wk1_iy="$dh1_placed_y" wk1_dx=50 wk1_dy=50 wk1_dw=500 wk1_dh=160
      if [[ "$APPLY_ICON_POSITIONS" == "1" ]] && lookup_disk_defaults "SDH1"; then
        wk1_ix="$dd_result_ix"; wk1_iy="$dd_result_iy"
        wk1_dx="$dd_result_dx"; wk1_dy="$dd_result_dy"
        wk1_dw="$dd_result_dw"; wk1_dh="$dd_result_dh"
      fi
      dh1_placed_y="$wk1_iy"
      local wk1_icon_to_copy
      wk1_icon_to_copy="$(hst_icon_update_file "$work_icon" "1" "$wk1_ix" "$wk1_iy" "$wk1_dx" "$wk1_dy" "$wk1_dw" "$wk1_dh")"
      fs_copy_file "$wk1_icon_to_copy" "$dh1_root/Disk.info"
      echo "  -> ${PART_NAMES[1]} (${PART_LABELS[1]}): $(basename "$work_icon") @ ${wk1_ix},${wk1_iy}"
    fi
  fi

  # EMU68BOOT (SD0:) - FAT partition (PiStorm only — skip if no FAT_ROOT)
  # Fixed position from DiskDefaults.csv, between Work (SDH1) and Work2+ (SDH2+).
  local prev_extra_y=$((dh1_placed_y + 70))  # default for Classic/MiSTer: right below Work
  if [[ -n "${FAT_ROOT:-}" ]]; then
    local sd0_ix=15 sd0_iy=60 sd0_dx=50 sd0_dy=50 sd0_dw=500 sd0_dh=160
    if [[ "$APPLY_ICON_POSITIONS" == "1" ]] && lookup_disk_defaults "SD0"; then
      sd0_ix="$dd_result_ix"; sd0_iy="$dd_result_iy"
      sd0_dx="$dd_result_dx"; sd0_dy="$dd_result_dy"
      sd0_dw="$dd_result_dw"; sd0_dh="$dd_result_dh"
    fi
    local sd0_icon=""
    sd0_icon="$(find_disk_icon_for_role sd0 "$icon_root" || true)"
    if [[ -n "$sd0_icon" ]]; then
      local sd0_icon_to_copy
      sd0_icon_to_copy="$(hst_icon_update_file "$sd0_icon" "1" "$sd0_ix" "$sd0_iy" "$sd0_dx" "$sd0_dy" "$sd0_dw" "$sd0_dh")"
      fs_copy_file "$sd0_icon_to_copy" "$FAT_ROOT/Disk.info"
      echo "  -> SD0 (EMU68BOOT): $(basename "$sd0_icon") @ ${sd0_ix},${sd0_iy}"
    fi
    prev_extra_y=$((sd0_iy + 70))  # PiStorm: DH2+ start one row below EMU68BOOT
  fi

  # DH2+ (Work2, Work3, ...) — same icon as Work, placed below EMU68BOOT on PiStorm
  # or below Work on Classic/MiSTer. prev_extra_y is set by the blocks above.
  if [[ -n "$work_icon" && "${NUM_PARTS:-0}" -gt 2 ]]; then
    local _pi
    for _pi in $(seq 2 $((NUM_PARTS - 1))); do
      if [[ "${PART_BYTES[$_pi]:-0}" -gt 0 && -n "${PART_PNS[$_pi]:-}" ]]; then
        local part_root="$partition_root_base/${PART_PNS[$_pi]}"
        if ! fs_dir "$part_root"; then
          continue
        fi
        local wk_ix=15 wk_iy="$prev_extra_y" wk_dx=50 wk_dy=50 wk_dw=500 wk_dh=160
        local device_name="SDH${_pi}"
        if [[ "$APPLY_ICON_POSITIONS" == "1" ]] && lookup_disk_defaults "$device_name"; then
          wk_ix="$dd_result_ix"; wk_iy="$dd_result_iy"
          wk_dx="$dd_result_dx"; wk_dy="$dd_result_dy"
          wk_dw="$dd_result_dw"; wk_dh="$dd_result_dh"
        fi
        local work_icon_to_copy
        work_icon_to_copy="$(hst_icon_update_file "$work_icon" "1" "$wk_ix" "$wk_iy" "$wk_dx" "$wk_dy" "$wk_dw" "$wk_dh")"
        fs_copy_file "$work_icon_to_copy" "$part_root/Disk.info"
        echo "  -> ${PART_NAMES[$_pi]} (${PART_LABELS[$_pi]}): $(basename "$work_icon") @ ${wk_ix},${wk_iy}"
        prev_extra_y=$((wk_iy + 70))
      fi
    done
  fi
}

ensure_transfer_drawer_icon() {
  local transfer_root="$1"

  # Dispatch-aware check: only act if the Transfer drawer exists in this partition.
  if [[ "$DISK_ENGINE" == "amigadiskkit" ]]; then
    _disk_parse_path "$transfer_root/Transfer"
    if [[ -z "$_dp_part" ]] || ! "$AMIGA_TOOLS" disk fs dir "$DISK_ENGINE_IMG" "$_dp_part" "$_dp_path" >/dev/null 2>&1; then
      return 0
    fi
  elif ! fs_dir "$transfer_root/Transfer"; then
    return 0
  fi

  # Pull an icon source from SYS_ROOT to a temp file, then write as Transfer.info.
  # Using a host-side temp + dispatch-aware wrappers so both engines work on any partition.
  local _tmp_icon="$STAGE/.transfer_icon_$$.$RANDOM.info"
  local _cand
  for _cand in \
    "$SYS_ROOT/Prefs/Env-Archive/Sys/def_drawer.info" \
    "$SYS_ROOT/Prefs/Env-Archive/Sys/Def_Drawer.info" \
    "$SYS_ROOT/Tools.info" \
    "$SYS_ROOT/System.info" \
    "$SYS_ROOT/Prefs.info"; do
    if fs_pull_file "$_cand" "$_tmp_icon" 2>/dev/null; then
      break
    fi
  done

  if [[ -f "$_tmp_icon" ]]; then
    fs_copy_file "$_tmp_icon" "$transfer_root/Transfer.info"
  fi
  rm -f "$_tmp_icon" 2>/dev/null || true
}

ensure_drawer_visible_in_root() {
  local sys_root="$1"
  local drawer_name="$2"
  local drawer_path="$sys_root/$drawer_name"

  # Only act when the drawer exists but has no .info icon.
  if ! fs_dir "$drawer_path"; then
    return 0
  fi
  if fs_dir "$drawer_path.info"; then
    return 0
  fi

  local icon_src=""
  local cand
  for cand in \
    "$sys_root/Prefs/Env-Archive/Sys/def_drawer.info" \
    "$sys_root/Prefs/Env-Archive/Sys/Def_Drawer.info" \
    "$sys_root/System.info" \
    "$sys_root/Tools.info"; do
    if fs_dir "$cand"; then
      icon_src="$cand"
      break
    fi
  done

  if [[ -n "$icon_src" ]]; then
    local _tmp_icon="$STAGE/.drawer-icon-$$.$RANDOM.info"
    if fs_pull_file "$icon_src" "$_tmp_icon" 2>/dev/null; then
      fs_copy_file "$_tmp_icon" "$drawer_path.info" 2>/dev/null || true
      rm -f "$_tmp_icon" 2>/dev/null || true
    else
      "$HST_IMAGER" fs copy "$icon_src" "$drawer_path.info" --force >/dev/null 2>&1 || true
      rm -f "$_tmp_icon" 2>/dev/null || true
    fi
  fi
}

ensure_fat95_in_sys() {
  local sys_root="$1"

  # Already present as a file?
  local tmp_check="$STAGE/.check-fat95.$$.$RANDOM"
  if fs_pull_file "$sys_root/L/fat95" "$tmp_check" >/dev/null 2>&1 || \
     fs_pull_file "$sys_root/L/FAT95" "$tmp_check" >/dev/null 2>&1; then
    rm -f "$tmp_check" >/dev/null 2>&1 || true
    return 0
  fi
  rm -f "$tmp_check" >/dev/null 2>&1 || true

  ensure_img_dir "$sys_root/L"

  # 1) Check Assets for a direct fat95 binary (skip in download-only mode)
  if [[ "${DOWNLOAD_ONLY:-0}" != "1" ]]; then
    for cand in \
      "$ASSETS_DIR/AmigaFiles/L/fat95" \
      "$ASSETS_DIR/AmigaFiles/L/FAT95" \
      "$ASSETS_DIR/AmigaFiles/System/L/fat95" \
      "$ASSETS_DIR/AmigaFiles/System/L/FAT95"; do
      if [[ -f "$cand" ]]; then
        echo "==> EMU68BOOT: installing fat95 from Assets: $cand"
        fs_copy_file "$cand" "$sys_root/L/fat95"
        return 0
      fi
    done
  fi

  # 2) Check Assets for fat95.lha (skip in download-only mode)
  local fat95_lha=""
  if [[ "${DOWNLOAD_ONLY:-0}" != "1" ]]; then
    for cand in \
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/fat95.lha" \
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/FAT95.LHA" \
      "$ASSETS_DIR/AmigaFiles/LocalAmigaPackages/Fat95.lha"; do
      [[ -f "$cand" ]] && fat95_lha="$cand" && break
    done
  fi

  # 3) Download from Aminet if not in Assets
  if [[ -z "$fat95_lha" ]]; then
    local url="http://aminet.net/pub/aminet/disk/misc/fat95.lha"
    fat95_lha="$STAGE/fat95.lha"
    echo "==> EMU68BOOT: downloading fat95.lha from Aminet"
    curl -fL --retry 3 --retry-delay 2 -o "$fat95_lha" "$url"
  fi

  # Extract and copy fat95 into SYS:L
  local tmp_fat="$STAGE/fat95-extract"
  rm -rf "$tmp_fat" >/dev/null 2>&1 || true
  mkdir -p "$tmp_fat"
  "$HST_IMAGER" fs extract "$fat95_lha" "$tmp_fat" --recursive --force >/dev/null 2>&1 || \
  "$HST_IMAGER" fs extract "$fat95_lha" "$tmp_fat" --recursive >/dev/null 2>&1 || true

  local fat_bin=""
  fat_bin="$(find "$tmp_fat" -type f \( -path '*/l/fat95' -o -path '*/L/FAT95' -o -name 'fat95' -o -name 'FAT95' \) 2>/dev/null | head -n1 || true)"
  if [[ -z "$fat_bin" ]]; then
    fat_bin="$(find "$tmp_fat" -type f -iname '*fat95' 2>/dev/null | head -n1 || true)"
  fi
  if [[ -n "$fat_bin" ]]; then
    echo "==> EMU68BOOT: installing fat95 -> SYS:L/fat95"
    fs_copy_file "$fat_bin" "$sys_root/L/fat95"
    if fs_dir "$sys_root/L/fat95"; then
      echo "==> EMU68BOOT: fat95 installed in SYS:L"
    fi
    return 0
  fi

  echo "==> WARN: fat95 not found after extraction; EMU68BOOT may not mount." >&2
  return 1
}

# --- Binary prefs patching (Windows parity) -----------------------------------

# write_screenmode_prefs()
#   Patch a template ScreenMode.prefs binary with the selected ModeID and color depth,
#   then copy to SYS:Prefs/ScreenMode.prefs on the image.
#   $1 = sys_root  (image path to SYS:)
#   $2 = mode_id   (8-char hex string, e.g. "50AF1101")
#   $3 = depth_hex  (2-char hex string, e.g. "08")

# ===========================================================================
# Binary prefs patching (screenmode, wbconfig, keymap)
# ===========================================================================
write_screenmode_prefs() {
  local sys_root="$1"
  local mode_id="$2"
  local depth_hex="$3"

  if [[ -z "$mode_id" || -z "$depth_hex" ]]; then
    echo "==> ScreenMode.prefs: skipping (no ModeID or depth specified)"
    return 0
  fi

  # Look for template in Assets (check Env-Archive/Sys first — that's where it lives)
  local template=""
  for cand in \
    "$ASSETS_DIR/AmigaFiles/System/Prefs/Env-Archive/Sys/screenmode.prefs" \
    "$ASSETS_DIR/AmigaFiles/PrefsTemplates/ScreenMode.prefs" \
    "$ASSETS_DIR/AmigaFiles/System/Prefs/ScreenMode.prefs"; do
    if [[ -f "$cand" ]]; then
      template="$cand"
      break
    fi
  done

  if [[ -z "$template" ]]; then
    echo "==> ScreenMode.prefs: template not found in Assets (skipping binary patching)"
    return 0
  fi

  local tmp_prefs="$STAGE/screenmode.prefs"
  cp "$template" "$tmp_prefs"

  # Patch ModeID at offset 50 (4 bytes, big-endian)
  local b0="${mode_id:0:2}" b1="${mode_id:2:2}" b2="${mode_id:4:2}" b3="${mode_id:6:2}"
  printf "\\x${b0}\\x${b1}\\x${b2}\\x${b3}" | \
    dd of="$tmp_prefs" bs=1 seek=50 count=4 conv=notrunc 2>/dev/null

  # Patch color depth at offset 59 (1 byte)
  printf "\\x${depth_hex}" | \
    dd of="$tmp_prefs" bs=1 seek=59 count=1 conv=notrunc 2>/dev/null

  # Write RTG to active screenmode.prefs + .User; keep native in .Native.
  # IPrefs (relocated after LoadWB via `Run >NIL: IPrefs`) reads the active
  # prefs and applies RTG. EndCLI closes the shell before IPrefs processes
  # the mode switch → no blocking window → no Intuition requester.
  #
  #   screenmode.prefs        = RTG user mode    (IPrefs applies RTG directly)
  #   screenmode.prefs.User   = RTG user mode    (UAEGFX UAE branch / rexx rename)
  #   screenmode.prefs.Native = NATIVE template  (UAE/Videocore fallback)
  #   Prefs/screenmode.prefs  = RTG user mode    (Prefs drawer copy)
  ensure_img_dir "$sys_root/Prefs/Env-Archive/Sys"
  fs_copy_file "$tmp_prefs" "$sys_root/Prefs/Env-Archive/Sys/screenmode.prefs.User"
  fs_copy_file "$tmp_prefs" "$sys_root/Prefs/Env-Archive/Sys/screenmode.prefs"
  fs_copy_file "$template"  "$sys_root/Prefs/Env-Archive/Sys/screenmode.prefs.Native"
  ensure_img_dir "$sys_root/Prefs"
  fs_copy_file "$tmp_prefs" "$sys_root/Prefs/screenmode.prefs"

  # Write ScreenModeChipset environment variable for CheckScreenModeandChipset.rexx
  # Native chipset modes use $0001/$0002 prefix (PAL/NTSC) = "OCS"
  # Everything else is RTG (VideoCore board prefixes $50xx)
  local chipset_type="RTG"
  local mode_prefix="${mode_id:0:4}"
  case "$mode_prefix" in
    0001|0002) chipset_type="OCS" ;;  # PAL/NTSC native modes
    *) chipset_type="RTG" ;;          # VideoCore RTG (board prefix varies by resolution)
  esac

  local tmp_chipset="$STAGE/ScreenModeChipset"
  printf "%s\n" "$chipset_type" > "$tmp_chipset"
  make_lf_only "$tmp_chipset"
  fs_copy_file "$tmp_chipset" "$sys_root/Prefs/Env-Archive/ScreenModeChipset"
  echo "==> Patched screenmode.prefs + .User (ModeID=0x${mode_id}, depth=0x${depth_hex}, chipset=$chipset_type)"
  echo "==> screenmode.prefs.Native left as native template (UAE Videocore fallback)"
}

# ensure_backdrop_presets()
#   Ensure backdrop preset IFFs exist in Prefs/Presets/Backdrops/ where
#   Workbench expects them. Some AmigaOS install media place the files
#   at SYS: root during ADF extraction; this function searches for them
#   and copies them to the correct location.
#   $1 = sys_host  (host-side staging directory for the System partition)
#   $2 = assets_dir (path to Assets directory, optional)
ensure_backdrop_presets() {
  local sys_host="$1"
  local assets_dir="${2:-}"

  local presets_dir="$sys_host/Prefs/Presets/Backdrops"
  mkdir -p "$presets_dir" >/dev/null 2>&1 || true

  # --- default_pal.iff ---
  local pal_target="$presets_dir/default_pal.iff"
  if [[ ! -f "$pal_target" ]]; then
    local _src=""
    for _cand in \
      "$sys_host/default_pal.iff" \
      "$sys_host/default.pal.iff" \
      "$sys_host/default_ntsc.iff" \
      "$sys_host/default.ntsc.iff" \
      "$sys_host/Prefs/Presets/default_pal.iff" \
      "$sys_host/Prefs/Presets/default.pal.iff" \
      "$assets_dir/AmigaFiles/System/Prefs/Presets/Backdrops/default_pal.iff" \
      "$assets_dir/AmigaFiles/System/Prefs/Presets/Backdrops/default.pal.iff" \
      ; do
      [[ -n "$_cand" && -f "$_cand" ]] && { _src="$_cand"; break; }
    done
    if [[ -n "$_src" ]]; then
      cp -f "$_src" "$pal_target" >/dev/null 2>&1 || true
      [[ -f "${_src}.info" && ! -f "${pal_target}.info" ]] && \
        cp -f "${_src}.info" "${pal_target}.info" >/dev/null 2>&1 || true
      echo "==> Backdrop preset restored: Prefs/Presets/Backdrops/default_pal.iff"
    fi
  fi

  # --- default_ntsc.iff ---
  local ntsc_target="$presets_dir/default_ntsc.iff"
  if [[ ! -f "$ntsc_target" ]]; then
    local _src=""
    for _cand in \
      "$sys_host/default_ntsc.iff" \
      "$sys_host/default.ntsc.iff" \
      "$sys_host/default_pal.iff" \
      "$sys_host/default.pal.iff" \
      "$sys_host/Prefs/Presets/default_ntsc.iff" \
      "$sys_host/Prefs/Presets/default.ntsc.iff" \
      "$assets_dir/AmigaFiles/System/Prefs/Presets/Backdrops/default_ntsc.iff" \
      "$assets_dir/AmigaFiles/System/Prefs/Presets/Backdrops/default.ntsc.iff" \
      ; do
      [[ -n "$_cand" && -f "$_cand" ]] && { _src="$_cand"; break; }
    done
    if [[ -n "$_src" ]]; then
      cp -f "$_src" "$ntsc_target" >/dev/null 2>&1 || true
      [[ -f "${_src}.info" && ! -f "${ntsc_target}.info" ]] && \
        cp -f "${_src}.info" "${ntsc_target}.info" >/dev/null 2>&1 || true
      echo "==> Backdrop preset restored: Prefs/Presets/Backdrops/default_ntsc.iff"
    fi
  fi

  # --- pattern.iff (fallback to default_pal/ntsc if no pattern found) ---
  local pattern_target="$presets_dir/pattern.iff"
  if [[ ! -f "$pattern_target" ]]; then
    local _src=""
    for _cand in \
      "$sys_host/Prefs/Presets/Backdrops/pattern.iff" \
      "$sys_host/pattern.iff" \
      "$pal_target" \
      "$ntsc_target" \
      "$assets_dir/AmigaFiles/System/Prefs/Presets/Backdrops/pattern.iff" \
      "$assets_dir/AmigaFiles/System/Prefs/Presets/Backdrops/default_pal.iff" \
      "$assets_dir/AmigaFiles/System/Prefs/Presets/Backdrops/default_ntsc.iff" \
      ; do
      [[ -n "$_cand" && -f "$_cand" ]] && { _src="$_cand"; break; }
    done
    if [[ -n "$_src" ]]; then
      cp -f "$_src" "$pattern_target" >/dev/null 2>&1 || true
      [[ -f "${_src}.info" && ! -f "${pattern_target}.info" ]] && \
        cp -f "${_src}.info" "${pattern_target}.info" >/dev/null 2>&1 || true
      echo "==> Backdrop preset restored: Prefs/Presets/Backdrops/pattern.iff"
    fi
  fi

  if [[ ! -f "$pattern_target" && ! -f "$pal_target" && ! -f "$ntsc_target" ]]; then
    echo "==> INFO: No custom backdrop image found (WBConfig backdrop mode still applies)"
  fi
}

# write_wbconfig_prefs()
#   Patch WBConfig.prefs byte 51 to enable/disable Workbench backdrop.
#   PC reference parity: Get-BackdropPrefs.ps1 patches offset 51 with 0x01/0x00.
#   The file already exists on the image from the AmigaOS installation.
#   $1 = sys_root  (image path to SYS:)
#   $2 = backdrop  ("1" = enabled, "0" = disabled)
write_wbconfig_prefs() {
  local sys_root="$1"
  local backdrop="${2:-1}"

  local prefs_path="Prefs/Env-Archive/Sys/WBConfig.prefs"
  local tmp_prefs="$STAGE/WBConfig.prefs"

  # Pull existing WBConfig.prefs from image (written by AmigaOS ADF/ISO extraction)
  if ! fs_pull_file "$sys_root/$prefs_path" "$tmp_prefs" >/dev/null 2>&1; then
    echo "==> WARN: WBConfig.prefs not found on image — cannot set backdrop"
    return 0
  fi

  local file_size
  file_size="$(wc -c < "$tmp_prefs" | tr -d ' ')"
  if [[ "$file_size" -lt 52 ]]; then
    echo "==> WARN: WBConfig.prefs too small (${file_size} bytes) — cannot patch backdrop"
    return 0
  fi

  # Patch byte 51 (0-indexed): 0x01 = backdrop on, 0x00 = backdrop off
  local byte_val="00"
  [[ "$backdrop" == "1" ]] && byte_val="01"
  printf "\\x${byte_val}" | dd of="$tmp_prefs" bs=1 seek=51 count=1 conv=notrunc 2>/dev/null

  # Write patched file back to image
  fs_copy_file "$tmp_prefs" "$sys_root/$prefs_path"
  echo "==> Patched WBConfig.prefs byte 51 = 0x${byte_val} (backdrop=$([ "$backdrop" = "1" ] && echo enabled || echo disabled))"
}

# Flag set by install_rtg_backdrop_png() when a PNG was installed to host staging.
_RTG_BACKDROP_PNG_INSTALLED=0

# install_rtg_backdrop_png()
#   Extract the resolution-matched PNG from Backdrops*.adf and install it as
#   Prefs/Presets/Backdrops/backdrop.png in the host staging directory.
#   Only runs for WB 3.2.x and when SCREEN_WIDTH/HEIGHT are set.
#   Sets _RTG_BACKDROP_PNG_INSTALLED=1 on success.
#   $1 = sys_host   (host-side staging dir for System partition)
#   $2 = adf_src    (directory containing ADF files)
#   $3 = width      (screen width, e.g. 1280)
#   $4 = height     (screen height, e.g. 720)
#   $5 = wb_version (DETECTED_WB_VERSION, e.g. "3.2")
install_rtg_backdrop_png() {
  local sys_host="$1"
  local adf_src="${2:-}"
  local screen_width="${3:-}"
  local screen_height="${4:-}"
  local wb_version="${5:-}"
  local wb_version_norm="$wb_version"

  _RTG_BACKDROP_PNG_INSTALLED=0

  if [[ "$wb_version_norm" =~ ([0-9]+\.[0-9]+(\.[0-9]+(\.[0-9]+)?)?) ]]; then
    wb_version_norm="${BASH_REMATCH[1]}"
  fi

  case "$wb_version_norm" in
    3.2*) ;;
    *)
      echo "==> INFO: Resolution backdrop skipped for Workbench version '${wb_version_norm:-unknown}'"
      return 0
      ;;
  esac

  if [[ -z "$screen_width" || -z "$screen_height" ]]; then
    echo "==> INFO: Resolution backdrop skipped - screen width/height not set"
    return 0
  fi

  if [[ -z "$adf_src" || ! -d "$adf_src" ]]; then
    echo "==> INFO: Resolution backdrop skipped - ADF source directory unavailable"
    return 0
  fi

  local _bd_adf=""
  for _try in "$adf_src"/Backdrops*.adf "$adf_src"/backdrops*.adf "$adf_src"/Backdrops*.ADF; do
    [[ -f "$_try" ]] && { _bd_adf="$_try"; break; }
  done
  if [[ -z "$_bd_adf" ]]; then
    _bd_adf="$(find "$adf_src" -type f \( -iname 'Backdrops*.adf' -o -iname 'Backdrops*.ADF' \) 2>/dev/null | head -n1 || true)"
  fi
  if [[ -z "$_bd_adf" ]]; then
    echo "==> INFO: Backdrops ADF not found in $(basename "$adf_src") — resolution backdrop skipped"
    return 0
  fi

  local _png_name="background_${screen_width}_${screen_height}.png"
  local _extract_dir="$STAGE/backdrops-adf-png"
  rm -rf "$_extract_dir" 2>/dev/null || true
  mkdir -p "$_extract_dir"

  echo "==> Extracting Backdrops ADF for resolution backdrop: $(basename "$_bd_adf")"
  extract_adf_to_host "$_bd_adf" "$_extract_dir" 2>/dev/null || true

  local _png_src=""
  for _cand in "$_extract_dir/$_png_name" "$_extract_dir/Prefs/Presets/Backdrops/$_png_name"; do
    [[ -f "$_cand" ]] && { _png_src="$_cand"; break; }
  done

  # Fallback: find closest available PNG by pixel area when exact match is absent
  local _used_label="${screen_width}x${screen_height}"
  if [[ -z "$_png_src" ]]; then
    local _target_area=$(( screen_width * screen_height ))
    local _best="" _best_diff=2147483647 _best_w="" _best_h=""
    for _f in "$_extract_dir"/background_*_*.png; do
      [[ -f "$_f" ]] || continue
      local _b; _b="$(basename "$_f" .png)"
      local _fw="${_b#background_}"; _fw="${_fw%_*}"
      local _fh="${_b##*_}"
      [[ "$_fw" =~ ^[0-9]+$ && "$_fh" =~ ^[0-9]+$ ]] || continue
      local _area=$(( _fw * _fh ))
      local _diff=$(( _area - _target_area ))
      [[ $_diff -lt 0 ]] && _diff=$(( -_diff ))
      if [[ $_diff -lt $_best_diff ]]; then
        _best_diff=$_diff; _best="$_f"; _best_w="$_fw"; _best_h="$_fh"
      fi
    done
    if [[ -n "$_best" ]]; then
      _png_src="$_best"
      _used_label="${_best_w}x${_best_h} (closest to ${screen_width}x${screen_height})"
    fi
  fi

  if [[ -n "$_png_src" ]]; then
    local _presets_dir="$sys_host/Prefs/Presets/Backdrops"
    mkdir -p "$_presets_dir" 2>/dev/null || true
    cp -f "$_png_src" "$_presets_dir/backdrop.png"
    _RTG_BACKDROP_PNG_INSTALLED=1
    echo "==> Installed resolution backdrop: Prefs/Presets/Backdrops/backdrop.png ($_used_label)"
  else
    echo "==> WARN: no backdrop PNG found in ADF for ${screen_width}x${screen_height}"
  fi

  rm -rf "$_extract_dir" 2>/dev/null || true
}

# write_wbpattern_backdrop()
#   Patch the first PTRN chunk in WBPattern.prefs so Workbench loads the
#   specified backdrop image. The PTRN chunk stores a BCPL string (length byte
#   + chars, no null) at chunk data offset 23; the first 23 bytes are flags/
#   header and are left unchanged.
#   Only call this after the image has been populated (after extract_host_to_sys).
#   $1 = sys_root      (image path to SYS:)
#   $2 = backdrop_path (AmigaOS path, e.g. Sys:Prefs/Presets/Backdrops/backdrop.png)
write_wbpattern_backdrop() {
  local sys_root="$1"
  local backdrop_path="${2:-Sys:Prefs/Presets/Backdrops/backdrop.png}"

  local _prefs_path="Prefs/Env-Archive/Sys/WBPattern.prefs"
  local _tmp="$STAGE/WBPattern_backdrop.prefs"

  if ! fs_pull_file "$sys_root/$_prefs_path" "$_tmp" 2>/dev/null; then
    echo "==> WARN: WBPattern.prefs not found on image — cannot set backdrop path"
    return 0
  fi

  if ! "$AMIGA_TOOLS" patch-wbpattern "$_tmp" "$backdrop_path" 2>/dev/null; then
    echo "==> WARN: Failed to patch WBPattern.prefs — backdrop path unchanged"
    return 0
  fi

  fs_copy_file "$_tmp" "$sys_root/$_prefs_path"
  rm -f "$_tmp" 2>/dev/null || true
  echo "==> Patched WBPattern.prefs: PTRN[0] → $backdrop_path"
}

# locale_to_keymap()
#   Map a locale code (e.g. "de") to an AmigaOS keymap name (e.g. "d").
#   Returns empty string for English or unknown locales (keep default usa1).
locale_to_keymap() {
  case "$(printf "%s" "$1" | tr '[:upper:]' '[:lower:]')" in
    de) echo "d" ;;
    fr) echo "f" ;;
    it) echo "i" ;;
    nl) echo "n" ;;
    dk) echo "dk" ;;
    es) echo "e" ;;
    se) echo "s" ;;
    uk|gb) echo "gb" ;;
    *) echo "" ;;
  esac
}

# write_keymap_prefs()
#   Patch input.prefs to use a specific keyboard layout (keymap).
#   The keymap name is stored in the IFF PREF/INPT chunk as a length-prefixed
#   string. We use Python to safely patch the binary structure.
#   $1 = sys_root   $2 = keymap_name (e.g. "d", "f", "gb")
write_keymap_prefs() {
  local sys_root="$1"
  local keymap_name="$2"

  if [[ -z "$keymap_name" ]]; then
    return 0
  fi

  local prefs_path="Prefs/Env-Archive/Sys/input.prefs"
  local tmp_prefs="$STAGE/input.prefs"

  if ! fs_pull_file "$sys_root/$prefs_path" "$tmp_prefs" >/dev/null 2>&1; then
    echo "==> WARN: input.prefs not found — cannot set keymap"
    return 0
  fi

  if [[ -z "${AMIGA_TOOLS:-}" ]] || [[ ! -x "$AMIGA_TOOLS" ]]; then
    echo "==> WARN: amiga-tools not available — cannot patch keymap in input.prefs"
    return 0
  fi

  "$AMIGA_TOOLS" patch-keymap "$tmp_prefs" "$keymap_name"

  if [[ $? -ne 0 ]]; then
    echo "==> WARN: Failed to patch input.prefs keymap"
    return 0
  fi

  fs_copy_file "$tmp_prefs" "$sys_root/$prefs_path"
}

# ===========================================================================
# ADF classification & Workbench version detection
# ===========================================================================
detect_locale_code_from_adf_name() {
  # Return a normalized 2-letter locale code (en,de,fr,...) for an ADF filename.
  # Accepts variants like:
  #   Locale-DE.adf, LocaleDE3_2.adf, locale_de.adf, locale-german.adf
  local adf_name="$1"
  local lower base tail
  lower="$(printf "%s" "$adf_name" | tr '[:upper:]' '[:lower:]')"
  base="${lower%.adf}"

  [[ "$base" == locale* ]] || return 1

  tail="${base#locale}"
  while [[ "$tail" == -* || "$tail" == _* ]]; do
    tail="${tail#?}"
  done

  case "$tail" in
    en*|english*) echo "en"; return 0 ;;
    de*|german*|deutsch*) echo "de"; return 0 ;;
    fr*|french*) echo "fr"; return 0 ;;
    it*|italian*) echo "it"; return 0 ;;
    nl*|dutch*) echo "nl"; return 0 ;;
    uk*|british*) echo "uk"; return 0 ;;
    dk*|danish*) echo "dk"; return 0 ;;
    es*|spanish*) echo "es"; return 0 ;;
    se*|swedish*) echo "se"; return 0 ;;
    gr*|greek*) echo "gr"; return 0 ;;
    no*|norwegian*) echo "no"; return 0 ;;
    pl*|polish*) echo "pl"; return 0 ;;
    pt*|portuguese*) echo "pt"; return 0 ;;
    ru*|russian*) echo "ru"; return 0 ;;
    tr*|turkish*) echo "tr"; return 0 ;;
    cz*|czech*) echo "cz"; return 0 ;;
    rs*|serbian*) echo "rs"; return 0 ;;
  esac

  # Handle versioned names like "Locale3.2.3-DE" (language code may appear later).
  for _code in en de fr it nl uk dk es se gr no pl pt ru tr cz rs; do
    if echo "$tail" | grep -Eq "(^|[^a-z])${_code}([^a-z]|$)"; then
      echo "$_code"
      return 0
    fi
  done

  return 1
}

is_base_locale_adf_name() {
  # Locale base disks sometimes have no language code (e.g. Locale.adf)
  # or only a version suffix (e.g. Locale3.2.3.adf). These should always
  # be included even when locale filtering is active, because they contain
  # shared Country definitions.
  local adf_name="$1"
  local lower base tail
  lower="$(printf "%s" "$adf_name" | tr '[:upper:]' '[:lower:]')"
  base="${lower%.adf}"

  [[ "$base" == locale* ]] || return 1

  tail="${base#locale}"
  while [[ "$tail" == -* || "$tail" == _* ]]; do
    tail="${tail#?}"
  done

  [[ -z "$tail" ]] && return 0
  echo "$tail" | grep -Eq '^[0-9.]+$' && return 0
  return 1
}

is_glowicons_adf_name() {
  local adf_name="$1"
  local lower
  lower="$(printf "%s" "$adf_name" | tr '[:upper:]' '[:lower:]')"
  [[ "$lower" == *glow*icon* ]]
}

is_4coloricons_adf_name() {
  local adf_name="$1"
  local lower
  lower="$(printf "%s" "$adf_name" | tr '[:upper:]' '[:lower:]')"
  echo "$lower" | grep -Eq '4[ _-]*(color|colour)[ _-]*icons?'
}

# detect_workbench_version()
#   Parse Workbench version from an optional ADF basename/path or the
#   WORKBENCH_ADF shell variable and set DETECTED_WB_VERSION. Preserves exact
#   version (e.g. 3.2.3, 3.2.2.1) for accurate logging. Defaults to 3.2 if
#   undetectable.
detect_workbench_version() {
  local adf_basename="${1:-}"
  if [[ -n "$adf_basename" ]]; then
    adf_basename="$(basename "$adf_basename" 2>/dev/null || true)"
  else
    adf_basename="$(basename "${WORKBENCH_ADF:-}" 2>/dev/null || true)"
  fi
  if [[ -z "$adf_basename" ]]; then
    DETECTED_WB_VERSION="3.2"
    echo "==> Workbench version detected: $DETECTED_WB_VERSION (default — no Workbench ADF found)"
    return 0
  fi

  # Strip extension and "Workbench" prefix to isolate version string
  # e.g. "Workbench3.2.adf" -> "3.2", "Workbench3.2.2.1.adf" -> "3.2.2.1"
  local ver_str=""
  ver_str="$(echo "$adf_basename" | sed -E 's/\.[aA][dD][fF]$//' | sed -E 's/^[Ww]orkbench//')"

  # Validate and preserve exact version
  case "$ver_str" in
    3.2.3)   DETECTED_WB_VERSION="3.2.3" ;;
    3.2.2.1) DETECTED_WB_VERSION="3.2.2.1" ;;
    3.2.2)   DETECTED_WB_VERSION="3.2.2" ;;
    3.2.1)   DETECTED_WB_VERSION="3.2.1" ;;
    3.2)     DETECTED_WB_VERSION="3.2" ;;
    3.1.4)   DETECTED_WB_VERSION="3.1.4" ;;
    3.1)     DETECTED_WB_VERSION="3.1" ;;
    3.9*)    DETECTED_WB_VERSION="3.9" ;;
    *)       DETECTED_WB_VERSION="3.2" ;;
  esac

  echo "==> Workbench version detected: $DETECTED_WB_VERSION (from $adf_basename)"
}

# Windows parity: OS 3.2 update media ships .Z-compressed files (including locale catalogs).
# The Installer normally expands these; since we merge ADFs directly, do it here.
decompress_amiga_z_files() {
  local root="$1"
  command -v uncompress >/dev/null 2>&1 || {
    echo "==> WARN: 'uncompress' not available; leaving .Z files compressed."
    return 0
  }
  [[ -d "$root" ]] || return 0

  local count=0
  while IFS= read -r -d '' zfile; do
    if uncompress -f "$zfile" >/dev/null 2>&1; then
      count=$((count + 1))
    fi
  done < <(find "$root" -type f -iname "*.Z" -print0 2>/dev/null)

  if [[ "$count" -gt 0 ]]; then
    echo "==> Decompressed $count .Z files"
  fi
}

# ===========================================================================
# LHA download helper
# ===========================================================================
download_lha_from_aminet() {
  local urls=(
    "https://aminet.net/util/arc/lha_68040.lha"
    "https://aminet.net/util/arc/lha_68020.lha"
    "https://aminet.net/util/arc/lha_68k.lha"
    "http://de.aminet.net/pub/aminet/util/arc/lha_68040.lha"
    "http://de.aminet.net/pub/aminet/util/arc/lha_68020.lha"
    "http://de.aminet.net/pub/aminet/util/arc/lha_68k.lha"
  )
  local archive=""
  for url in "${urls[@]}"; do
    local base
    base="$(basename "$url")"
    archive="$STAGE/$base"
    echo "==> Downloading mandatory LhA package: $base" >&2
    if curl -fL --connect-timeout 5 --max-time 20 --retry 1 --retry-delay 1 -o "$archive" "$url" >/dev/null 2>&1; then
      echo "$archive"
      return 0
    fi
  done
  return 1
}

# ===========================================================================
# IPrefs relocation (RTG boot fix)
# ===========================================================================
relocate_iprefs_after_loadwb() {
  local sys_root="$1"

  local tmp_ss="$STAGE/Startup-Sequence.iprefs-relocate"
  # AmigaOS is case-insensitive; the file may be Startup-Sequence or Startup-sequence
  local ss_pulled=0 ss_name=""
  for ss_try in "Startup-Sequence" "Startup-sequence" "startup-sequence"; do
    if fs_pull_file "$sys_root/S/$ss_try" "$tmp_ss"; then
      ss_pulled=1; ss_name="$ss_try"; break
    fi
  done
  if [[ "$ss_pulled" -eq 0 ]]; then
    echo "==> IPrefs relocation: could not pull Startup-Sequence from image"
    return 0
  fi
  make_lf_only "$tmp_ss"

  # Already patched?
  if grep -q 'Amiga Imager: IPrefs relocated after LoadWB' "$tmp_ss" 2>/dev/null; then
    echo "==> IPrefs relocation: already patched, skipping"
    return 0
  fi

  # Find the IPrefs invocation line (plain or 'Run >NIL: IPrefs' form).
  # WB 3.2 does NOT have IPrefs in the Startup-Sequence (it's in WBStartup/).
  local iprefs_line
  iprefs_line=$(grep -inE '^[[:space:]]*(Run[[:space:]]+>NIL:[[:space:]]+)?(C:)?IPrefs[[:space:]]*$' \
    "$tmp_ss" | head -1 | cut -d: -f1 || true)

  if [[ -n "$iprefs_line" ]]; then
    echo "==> IPrefs relocation: IPrefs found at line $iprefs_line (will relocate)"
  else
    echo "==> IPrefs relocation: IPrefs not in Startup-Sequence (WB 3.2 uses WBStartup/IPrefs)"
  fi

  # Find the EndCLI line (insert IPrefs + WBDock just before it)
  local endcli_line
  endcli_line=$(grep -inE '^[[:space:]]*EndCLI' "$tmp_ss" | tail -1 | cut -d: -f1 || true)

  if [[ -z "$endcli_line" ]]; then
    echo "==> IPrefs relocation: EndCLI not found, skipping"
    return 0
  fi

  echo "==> IPrefs relocation: EndCLI at line ${endcli_line}"

  # Check if a delayed WBDock launcher script exists AND isn't already in the file
  # (inject_wbdock_into_startup may have already added it)
  local has_wbdock_launcher=0
  if fs_dir "$sys_root/S/StartWBDock"; then
    if ! grep -q 'StartWBDock' "$tmp_ss" 2>/dev/null; then
      has_wbdock_launcher=1
    fi
  fi

  awk -v iprefs_ln="${iprefs_line:-0}" -v endcli_ln="$endcli_line" -v wbdock="$has_wbdock_launcher" '
    # If IPrefs was found in the script, comment it out at its original position
    iprefs_ln > 0 && NR == iprefs_ln {
      print "; Amiga Imager: IPrefs relocated after LoadWB (avoid RTG requester)"
      next
    }
    NR == endcli_ln {
      # Insert Run >NIL: IPrefs before EndCLI so the screen switch happens
      # after LoadWB opens Workbench. EndCLI closes the shell before IPrefs
      # processes the switch → no Intuition requester.
      print "Run >NIL: IPrefs"
      if (wbdock == 1) {
        print "; Amiga Imager: delayed WBDock launch (skip on first boot, runs after reboot)"
        print "IF NOT $FIRSTTIMEBOOT EQ \"TRUE\""
        print "  Run >NIL: Execute SYS:S/StartWBDock"
        print "ENDIF"
      }
      print ""
    }
    { print }
  ' "$tmp_ss" > "$tmp_ss.new"

  if ! cmp -s "$tmp_ss" "$tmp_ss.new"; then
    mv "$tmp_ss.new" "$tmp_ss"
    make_lf_only "$tmp_ss"
    chmod +x "$tmp_ss"  # Amiga 's' bit: Startup-Sequence is an AmigaDOS script
    fs_copy_file "$tmp_ss" "$sys_root/S/$ss_name"
    echo "==> Patched: IPrefs relocated after LoadWB (Run >NIL: IPrefs before EndCLI)"
    if [[ "$has_wbdock_launcher" -eq 1 ]]; then
      echo "==> Patched: WBDock delayed launch added to Startup-Sequence"
    fi
  else
    rm -f "$tmp_ss.new"
    echo "==> IPrefs relocation: no change needed"
  fi
}

# inject_wbdock_into_startup()
#   Insert 'Run >NIL: Execute SYS:S/StartWBDock' before EndCLI in the
#   Startup-Sequence, if StartWBDock exists and hasn't been injected yet.
#   Call this AFTER install_wbdock() to handle the case where WBDock is
#   installed after relocate_iprefs_after_loadwb() has already run.
#   $1 = sys_root
inject_wbdock_into_startup() {
  local sys_root="$1"

  # Only run if StartWBDock was created by install_wbdock
  if ! fs_dir "$sys_root/S/StartWBDock"; then
    return 0
  fi

  local tmp_ss="$STAGE/Startup-Sequence.wbdock-inject"
  local ss_name=""
  for ss_try in "Startup-Sequence" "Startup-sequence" "startup-sequence"; do
    if fs_pull_file "$sys_root/S/$ss_try" "$tmp_ss"; then
      ss_name="$ss_try"; break
    fi
  done
  [[ -z "$ss_name" ]] && return 0
  make_lf_only "$tmp_ss"

  # Already injected?
  if grep -q 'StartWBDock' "$tmp_ss" 2>/dev/null; then
    return 0
  fi

  local endcli_line
  endcli_line=$(grep -inE '^[[:space:]]*EndCLI' "$tmp_ss" | tail -1 | cut -d: -f1 || true)
  [[ -z "$endcli_line" ]] && return 0

  awk -v endcli_ln="$endcli_line" '
    NR == endcli_ln {
      print "; Amiga Imager: delayed WBDock launch (skip on first boot, runs after reboot)"
      print "IF NOT $FIRSTTIMEBOOT EQ \"TRUE\""
      print "  Run >NIL: Execute SYS:S/StartWBDock"
      print "ENDIF"
      print ""
    }
    { print }
  ' "$tmp_ss" > "$tmp_ss.new"

  if ! cmp -s "$tmp_ss" "$tmp_ss.new"; then
    mv "$tmp_ss.new" "$tmp_ss"
    make_lf_only "$tmp_ss"
    chmod +x "$tmp_ss"  # Amiga 's' bit: Startup-Sequence is an AmigaDOS script
    fs_copy_file "$tmp_ss" "$sys_root/S/$ss_name"
    echo "==> Patched: WBDock delayed launch added to Startup-Sequence"
  else
    rm -f "$tmp_ss.new"
  fi
}

# ===========================================================================
# AmigaDiskKit diagnostic gate
# ===========================================================================
# If AMIGADISKKIT_INSPECT=1, run amiga-tools disk rdb-info on the full image
# and log the output alongside hst-imager for side-by-side comparison.
# Zero build impact when the env var is not set.
amigadiskkit_inspect_rdb() {
  [[ "${AMIGADISKKIT_INSPECT:-0}" == "1" ]] || return 0
  local img="$1"
  [[ -f "$img" ]] || return 0
  echo "==> [AmigaDiskKit] amiga-tools disk rdb-info $(basename "$img")"
  "$AMIGA_TOOLS" disk rdb-info "$img" 2>&1 || true
}
