#!/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; }

# 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

  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
}

# ===========================================================================
# Filesystem helpers (hst-imager wrappers)
# ===========================================================================
fs_mkdir_p() {
  # best-effort mkdir (ignore errors if already exists)
  local path="$1"
  "$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"
  "$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 ! "$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"
  cp -a "$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

  # 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"
    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"
  perl -pi -e 's/\r\n/\n/g; s/\r/\n/g' "$f"
}

fs_copy() {
  local src="$1"; local dst="$2"; shift 2
  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.
  local dst="$1"
  "$HST_IMAGER" fs mkdir "$dst" >/dev/null 2>&1 || true
  "$HST_IMAGER" fs dir "$dst" >/dev/null 2>&1 || die "Failed to create/access destination drawer: $dst"
}

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"
  if ! "$HST_IMAGER" fs dir "$parent" >/dev/null 2>&1; then
    die "Failed to create/access destination drawer: $parent"
  fi

  # 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")"
  "$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
}

# ===========================================================================
# 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"

  "$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"

  # Try lha first (hst-imager has IndexOutOfRangeException on LHA archives on Intel macOS x64).
  # 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
      return 0
    fi
  fi
  "$HST_IMAGER" fs extract "$lha_file" "$host_dst" --recursive --force 2>/dev/null && return 0
  "$HST_IMAGER" fs extract "$lha_file" "$host_dst" --recursive        2>/dev/null && return 0
  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 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"
    # SD0 mount + EMU68BOOT assign — PiStorm only (FAT boot partition)
    if [[ -n "${FAT_ROOT:-}" ]]; then
      echo "; Mount SD0 (PiStorm FAT boot volume) if DOSDriver present."
      echo "; Pi4vsPi3_Pistorm OneTimeRun renames SD0Pi4/SD0Pi3 -> SD0 at first boot."
      echo "IF EXISTS DEVS:DOSDrivers/SD0"
      echo "  Mount >NIL: DEVS:DOSDrivers/SD0"
      echo "ENDIF"
      echo "; EMU68BOOT: assign for backward compatibility (points to SD0:)"
      echo "IF EXISTS SD0:"
      echo "  Assign >NIL: EMU68BOOT: SD0:"
      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"
    # DH0: is typically auto-assigned by the ROM to the boot volume — do NOT
    # reassign it, since on V47+ this may fail with returncode 20.
    # DH1: Work: is optional — only assign if Work: exists AND DH1: isn't already set.
    echo "FAILAT 21"
    echo "IF EXISTS Work:"
    echo "  Assign >NIL: DH1: Work:"
    echo "ENDIF"
    echo "FAILAT 10"
  } >"$tmp_assigns"
  make_lf_only "$tmp_assigns"

  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"
  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
        perl -pi -e 's/^(\s*)AddBuffers(.*DF0:.*)$/\1; Amiga Imager: DF0 AddBuffers toggle\n\1IF "$HAS_FLOPPY" EQ "TRUE"\n\1  AddBuffers\2\n\1ENDIF/i' "$tmp_ss"
        ss_changed=1
      fi
    fi

    if ! grep -qi 'amigaimager-assigns' "$tmp_ss"; then
      # Insert before EndCLI if present, otherwise append.
      if grep -qi '^EndCLI' "$tmp_ss"; then
        perl -0777 -pi -e 's/^EndCLI.*$/; Amiga Imager assigns\nExecute S:amigaimager-assigns\n\n$&/mi' "$tmp_ss"
      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
        perl -0777 -pi -e 's/^(LoadWB.*$)/Execute S:User-Startup\n\n$1/mi' "$tmp_ss"
        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
          perl -0777 -pi -e 's/^EndCLI.*$/Execute S:User-Startup\n\n$&/mi' "$tmp_ss"
        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"
      fs_copy_file "$tmp_ss" "$sys_root/S/Startup-Sequence"
    fi
  fi
}

# ---------------------------------------------------------------------------
# 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
  for _junk in \
    ActivateCDROM ActivateCDROM.info \
    HDSetup HDSetup.info HDTools HDTools.info \
    Installer Installer.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
  # 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 ---
  if [[ -d "$sys_host/Keymaps" ]]; then
    _km_count=0
    mkdir -p "$sys_host/Devs/Keymaps"
    for _km in "$sys_host/Keymaps"/*; do
      [[ -e "$_km" ]] || continue
      _km_name="$(basename "$_km")"
      if [[ ! -e "$sys_host/Devs/Keymaps/$_km_name" ]]; then
        cp -R "$_km" "$sys_host/Devs/Keymaps/$_km_name"
        _km_count=$((_km_count + 1))
      fi
    done
    rm -rf "$sys_host/Keymaps"
    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"
  # Use --force directly (no fallback). The fs_copy fallback without --force is
  # broken for recursive copies: the first attempt partially writes files, then
  # the retry without --force 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"
  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
}

# Check whether a package name appears in the comma-separated $PACKAGES list.
package_is_selected() {
  local pkg="$1"
  # 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
  # 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)"
      if [[ -n "$_hint" ]] && printf '%s\n' "$_hint" | grep -qi "^${pkg}$"; then
        return 0
      fi
    done
  fi
  [[ -n "$PACKAGES" ]] || return 1
  echo ",$PACKAGES," | grep -qi ",$pkg,"
}

# -----------------------------------------------------------------------------
# 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 ! "$HST_IMAGER" fs dir "$sys_root/S/user-startup" >/dev/null 2>&1; then
    if "$HST_IMAGER" fs dir "$sys_root/S/User-Startup" >/dev/null 2>&1; 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
# ===========================================================================
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="PFS3" ;;
    *)        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"
    # The initial PiStorm format registered only DOS3 in the RDB. Any
    # subsequent `rdb part add ... DOS7` will fail with "File system with
    # DOS type 'DOS7' not found in Rigid Disk Block" unless we register
    # DOS7 here. The FastFileSystem binary handles both dostypes, so we
    # point DOS7 at the same binary.
    ensure_rdb_has_dos7_fs
  fi
}

# 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: RDB, 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)"
  run "$HST_IMAGER" rdb fs add "$RDB" "$FFS_PATH" DOS7 --name FastFileSystem \
    || die "Failed to register DOS7 filesystem in RDB"
}

# ===========================================================================
# 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"
      "$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
      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 ]] && hst_amiga_available; then
      if "$HST_AMIGA" icon tooltypes export "$_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 ]] && hst_amiga_available; then
            "$HST_AMIGA" icon tooltypes import "$_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="$("$HST_IMAGER" fs dir "$sys_root/$_subdir" --recursive 2>/dev/null || true)"
    while IFS= read -r _line; do
      # Parse hst-imager listing: "Name | Size | ..."
      local _name
      _name="$(echo "$_line" | sed 's/|.*//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')"
      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"
}

# 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" ;;
    lha_68040.lha|lha_68020.lha|lha_68k.lha) echo "LhA" ;;
    ahiusr_4.18.lha) echo "AHI" ;;
    amigaamp.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" ;;
    igame.lha) echo "iGame" ;;
    lumiftp_1.0.201.lha|lumiftp_1.0.258.lha) echo "LumiFTP" ;;
    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 (hst-amiga wrappers)
# ===========================================================================
# --- ToolTypes modification helpers (Windows parity) --------------------------

# Check if hst-amiga is available
hst_amiga_available() {
  [[ -n "${HST_AMIGA:-}" && -x "${HST_AMIGA:-}" ]] && return 0
  # Also check the default path
  [[ -x "$DEFAULT_HST_AMIGA" ]] && { HST_AMIGA="$DEFAULT_HST_AMIGA"; return 0; }
  return 1
}

# export_tooltypes()
#   Export .info file tooltypes to a text file using hst-amiga.
#   $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"
  hst_amiga_available || return 1
  "$HST_AMIGA" icon tooltypes export "$info_path" "$output_file" 2>/dev/null
}

# import_tooltypes()
#   Import tooltypes from a text file into an .info file using hst-amiga.
#   $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"
  hst_amiga_available || return 1
  "$HST_AMIGA" icon tooltypes import "$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:-}"

  if ! hst_amiga_available; then
    echo "==> WARN: hst-amiga not available; cannot modify tooltype $tt_name for $info_path"
    return 0
  fi

  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
  perl -pi -e 's/^\xEF\xBB\xBF//' "$tmp_tt"

  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
    perl -pi -e "s/^${tt_name}.*/${line}/i" "$tmp_tt"
  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"

  if ! hst_amiga_available; then
    echo "==> WARN: hst-amiga not available; cannot replace tooltype '$old_line' for $info_path"
    return 0
  fi

  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
  perl -pi -e 's/^\xEF\xBB\xBF//' "$tmp_tt"

  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 ! hst_amiga_available; then
    echo "==> DOpus ToolType fix: skipped (hst-amiga not available)"
    return 0
  fi

  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 "$HST_IMAGER" fs dir "$sys_root/System/CLICon" >/dev/null 2>&1; 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"
    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, copied from Picasso96 generic)
#   - 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 ---
  # The ZZ9000 installer creates Devs/Monitors/ZZ9000 by copying the generic
  # Picasso96 monitor binary. We look for Picasso96 first (installed by Classic
  # RTG handler), then Generic/Altais as fallbacks.
  local _mon_src=""
  for _try_mon in Picasso96 Generic Altais; do
    if "$HST_IMAGER" fs dir "$sys_root/Devs/Monitors/$_try_mon" >/dev/null 2>&1; then
      _mon_src="$_try_mon"
      break
    fi
  done
  if [[ -n "$_mon_src" ]]; then
    local _mon_tmp="$STAGE/zz9000-monitor.bin"
    if fs_pull_file "$sys_root/Devs/Monitors/$_mon_src" "$_mon_tmp" >/dev/null 2>&1; then
      fs_copy_file "$_mon_tmp" "$sys_root/Devs/Monitors/ZZ9000"
      echo "==> ZZ9000: created Devs/Monitors/ZZ9000 (copied from $_mon_src)"
      rm -f "$_mon_tmp"
    fi
  else
    echo "==> WARN: ZZ9000: no P96 monitor file 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
}

# 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 "$HST_IMAGER" fs dir "$sys_root/C/mecho" >/dev/null 2>&1; 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 "$HST_IMAGER" fs dir "$sys_root/Libs/screennotify.library" >/dev/null 2>&1; 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_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"
  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"
  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:-}"

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

  local cmd=( "$HST_AMIGA" 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

  hst_amiga_available || return 1
  [[ -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
    # hst-amiga prepends a UTF-8 BOM to exported tooltype files; without stripping it,
    # the key-removal AWK sees "\xEF\xBB\xBFSCSI_DEVICE_NAME" on the first line and
    # fails to match "SCSI_DEVICE_NAME", so the old value survives alongside the new one.
    perl -pi -e 's/^\xEF\xBB\xBF//' "$tmp_existing"
    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() {
  if ! hst_amiga_available; then
    echo "==> Tooltypes: hst-amiga not available (skipping CSV-driven tooltype updates)"
    return 0
  fi

  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() {
  if ! hst_amiga_available; then
    echo "==> Icon positions: hst-amiga not available (skipping CSV-driven updates)"
    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 }
    # 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"))"
}

# --- 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" != "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)"
    _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 for platforms whose
  # .card driver implements the Aminet shareware (v40) .card API:
  #   - PiStorm's VideoCore.card (Emu68-tools)  → needs v40
  #   - MiSTer's MiSTer.card (MiSTer_RTG.lha)   → needs v40
  # Classic builds use real P96 hardware (ZZ9000, Picasso IV, CyberVision,
  # etc.) whose .card drivers require the v43+ iComp ABI — forcing v40 here
  # breaks those cards. P96_TRUST_ARCHIVE_ABI=1 is set by build-classic.sh
  # to mean "use the user-supplied archive as-is, don't force v40 compat".
  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)"
          _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
    # Classic path: trust the user's archive regardless of rtg.library version.
    # Real P96 cards (ZZ9000, Picasso IV, CyberVision, etc.) need the matching
    # iComp ABI; the v40 shareware fallback is for VideoCore.card / MiSTer.card
    # only.
    echo "==> rtg.library v${_rtg_lib_ver:-unknown} — using user-supplied archive as-is (Classic build)"
    _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) ---
  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"
    done < <(find "$_p96_core_root/Libs/Picasso96" -maxdepth 1 -type f -print0)
  fi

  # --- Devs/Monitors/ (from core source) ---
  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
      # Picasso96/Picasso96.info: install on Classic (P96_TRUST_ARCHIVE_ABI=1)
      # so real P96 cards (ZZ9000, Picasso IV, CyberVision) can use it as the
      # source for card-specific monitor copies (e.g. Devs/Monitors/ZZ9000).
      # Skip on PiStorm (uses Videocore/Uaegfx aliases instead, installed
      # below) and MiSTer (ships its own Devs/Monitors/MiSTer).
      case "$_bn" in
        Picasso96|Picasso96.info)
          [[ "${P96_TRUST_ARCHIVE_ABI:-}" != "1" ]] && continue
          ;;
      esac
      fs_copy_file "$_f" "$sys_root/Devs/Monitors/$_bn"
    done
  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)"
  rm -rf "$tmp_p96" 2>/dev/null || true
}

# ---------------------------------------------------------------------------
# _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 hst_amiga_available && "$HST_IMAGER" fs dir "$sys_root/Programs/AHI/AHI" >/dev/null 2>&1; 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 "$HST_AMIGA" icon update "$ahi_tmp.info" -t 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 hst_amiga_available && "$HST_IMAGER" fs dir "$sys_root/Programs/DOpus" >/dev/null 2>&1; 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 "$HST_AMIGA" icon update "$dopus_tmp.info" -t 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: archive ships no top-level drawer icon. Create one from
  # def_drawer.info so the folder is visible in Workbench icon-view.
  if "$HST_IMAGER" fs dir "$sys_root/Programs/HippoPlayer" >/dev/null 2>&1; then
    if ! "$HST_IMAGER" fs dir "$sys_root/Programs/HippoPlayer.info" >/dev/null 2>&1; then
      if "$HST_IMAGER" fs dir "$sys_root/Prefs/Env-Archive/Sys/def_drawer.info" >/dev/null 2>&1; then
        "$HST_IMAGER" fs copy "$sys_root/Prefs/Env-Archive/Sys/def_drawer.info" \
          "$sys_root/Programs/HippoPlayer.info" --force >/dev/null 2>&1 || true
        echo "==> HippoPlayer: created drawer icon from def_drawer.info"
      fi
    fi
  fi

  # 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).
  if hst_amiga_available; then
    local prog_tmp="$STAGE/programs-info-resize.$$"
    if fs_pull_file "$sys_root/Programs.info" "$prog_tmp.info" >/dev/null 2>&1; then
      "$HST_AMIGA" 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
  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 ! "$HST_IMAGER" fs dir "$sys_root/Programs" >/dev/null 2>&1; then
    _vprog_warn "SYS:Programs drawer missing"
    return 0
  fi

  _check_drawer_info() {
    local app="$1"
    local tmp_v="$STAGE/vprog-${app}-$$.info"
    "$HST_IMAGER" fs dir "$sys_root/Programs/$app" >/dev/null 2>&1 || 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 ! "$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

  # 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 ! "$HST_IMAGER" fs dir "$sys_root/S/user-startup" >/dev/null 2>&1; then
          if "$HST_IMAGER" fs dir "$sys_root/S/User-Startup" >/dev/null 2>&1; 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 ! "$HST_IMAGER" fs dir "$sys_root/Programs/Roadie" >/dev/null 2>&1; then
    return 0
  fi

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

  # Ensure Programs/Roadie.info exists like SysInfo does.
  if ! 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

  # The archive ships Roadie.info as a TOOL icon (for the binary).
  # When mapped to Programs/Roadie.info it must be a DRAWER icon so clicking
  # opens the drawer instead of trying to execute "Roadie" (a directory).
  if hst_amiga_available; 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 "$HST_AMIGA" icon update "$tmp_ri.info" -t 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 "$HST_IMAGER" fs dir "$cand_ib" >/dev/null 2>&1; 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" != "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)
install_ibrowse_from_official() {
  local sys_root="$1"
  local ibrowse_dir="$2"
  local key_file="${3:-}"

  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

      # 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

      # Verify codecs were copied (case-insensitive search)
      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
        local codec_count
        codec_count="$(find "$codec_dir" -maxdepth 1 -type f -name "*.codec" 2>/dev/null | wc -l | tr -d ' ')"
        echo "  -> Found $codec_count codecs in: $(basename "$codec_dir")"

        # Check for patch directories (case-insensitive)
        local patch_dir=""
        patch_dir="$(find "$ib_root" -maxdepth 1 -type d -iname "codecs040fpu" 2>/dev/null | head -n1)"
        if [[ -n "$patch_dir" && -d "$patch_dir" ]]; then
          local patch_count
          patch_count="$(find "$patch_dir" -maxdepth 1 -type f -name "*.spatch" 2>/dev/null | wc -l | tr -d ' ')"
          echo "  -> Found $patch_count spatch files in: $(basename "$patch_dir")"
        else
          echo "  -> WARN: No codecs040fpu directory found for codec patching"
        fi
      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 IBrowse libraries (javascript.library, etc.) to SYS:Libs/
      # IBrowse ships these in its own Libs/ subdirectory, but AmigaOS
      # OpenLibrary() only searches LIBS: (= SYS:Libs/).  Without this,
      # IBrowse shows "Unable to open javascript.library" at launch.
      local ib_libs_dir=""
      ib_libs_dir="$(find "$ib_root" -maxdepth 1 -type d -iname "libs" 2>/dev/null | head -n1)"
      if [[ -z "$ib_libs_dir" ]]; then
        # Some archive layouts put Libs/ outside the main IBrowse directory
        ib_libs_dir="$(find "$tmp_ib" -maxdepth 1 -type d -iname "libs" 2>/dev/null | head -n1)"
      fi
      if [[ -n "$ib_libs_dir" && -d "$ib_libs_dir" ]]; then
        ensure_img_dir "$sys_root/Libs"
        local lib_count=0
        for lib_file in "$ib_libs_dir"/*.library; do
          [[ -f "$lib_file" ]] || continue
          local lib_name
          lib_name="$(basename "$lib_file")"
          fs_copy_file "$lib_file" "$sys_root/Libs/$lib_name"
          echo "  -> Installed $lib_name to SYS:Libs/"
          lib_count=$((lib_count + 1))
        done
        if [[ $lib_count -eq 0 ]]; then
          echo "  -> No .library files found in IBrowse Libs/ directory"
        fi
      else
        # Last resort: search entire extraction for javascript.library
        local js_lib=""
        js_lib="$(find "$tmp_ib" -type f -iname "javascript.library" 2>/dev/null | head -n1)"
        if [[ -n "$js_lib" && -f "$js_lib" ]]; then
          ensure_img_dir "$sys_root/Libs"
          fs_copy_file "$js_lib" "$sys_root/Libs/javascript.library"
          echo "  -> Installed javascript.library to SYS:Libs/ (from $(dirname "$js_lib"))"
        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/
      ensure_img_dir "$amissl_dest/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")"
          done
        fi
      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"
    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:"
    "$HST_IMAGER" fs dir "$sys_root/Programs/Ibrowse" 2>/dev/null | head -20 || echo "     (failed to list)"
    echo "     Programs/Ibrowse/Images contents:"
    "$HST_IMAGER" fs dir "$sys_root/Programs/Ibrowse/Images" 2>/dev/null | head -20 || echo "     (failed to list or not present)"
    echo "     Programs/Ibrowse/codecs contents:"
    "$HST_IMAGER" fs dir "$sys_root/Programs/Ibrowse/codecs" 2>/dev/null | head -10 || echo "     (failed to list or not present)"
    echo "     Programs/Ibrowse/codecs040fpu contents:"
    "$HST_IMAGER" fs dir "$sys_root/Programs/Ibrowse/codecs040fpu" 2>/dev/null | head -10 || echo "     (failed to list or not present)"
    echo "     SYS:Libs/ IBrowse libraries:"
    "$HST_IMAGER" fs dir "$sys_root/Libs/javascript.library" 2>/dev/null || echo "     javascript.library: NOT present"
    echo "     C/spatch check:"
    "$HST_IMAGER" fs dir "$sys_root/C/spatch" 2>/dev/null || echo "     (not present)"
  fi
}

# 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)
  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") && _wd_extracted=1
  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
  if [[ "${INCLUDE_IGAME:-0}" -eq 1 ]]; then
    echo "SYS:Programs/iGame/iGame" >> "$cfg_host"
  fi
  if [[ "${INCLUDE_LUMIFTP:-0}" -eq 1 ]]; then
    echo "SYS:Programs/LumiFTP/LumiFTP" >> "$cfg_host"
  fi
  if [[ "${INCLUDE_AMIGAAMP:-0}" -eq 1 ]]; then
    echo "SYS:Programs/AmigaAMP/AmigaAMP" >> "$cfg_host"
  fi
  if [[ "${INCLUDE_RIVA:-0}" -eq 1 ]]; then
    echo "SYS:Programs/RiVA" >> "$cfg_host"
  fi
  # Add platform-specific network entry
  if [[ -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"
  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
  perl -pi -e 's/^\xEF\xBB\xBF//' "$tmp_tt"

  # DELAY — dynamic value; set separately before the static defaults loop
  if grep -qi "^DELAY" "$tmp_tt" 2>/dev/null; then
    perl -pi -e "s|^DELAY.*|DELAY=${delay}|i" "$tmp_tt"
  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
      perl -pi -e "s|^${_tt_key}.*|${_tt_line}|i" "$tmp_tt"
    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:-}"

  if ! hst_amiga_available; then
    echo "$src_info"
    return 0
  fi

  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 use_hst_amiga=0
  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
    if hst_amiga_available; then
      use_hst_amiga=1
      echo "==> Icon positioning: applying disk icon geometry via hst-amiga (DiskDefaults.csv)"
    else
      echo "==> Icon positioning: keeping icon geometry from source icons"
    fi
  else
    if hst_amiga_available; then
      use_hst_amiga=1
      echo "==> Disk icons: applying default left-column positions"
    else
      echo "==> Disk icons: keeping icon geometry from source icons"
    fi
  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/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
  }

  # Per-disk positioning from DiskDefaults.csv with hardcoded fallbacks

  # 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"
    if [[ "$use_hst_amiga" -eq 1 ]]; then
      dh0_icon_to_copy="$(hst_icon_update_file "$dh0_icon" "1" "$dh0_ix" "$dh0_iy" "$dh0_dx" "$dh0_dy" "$dh0_dw" "$dh0_dh")"
    fi
    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=""
  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="$IMG/mbr/2/rdb/${PART_PNS[1]}"
    if "$HST_IMAGER" fs dir "$dh1_root" >/dev/null 2>&1; 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="$work_icon"
      if [[ "$use_hst_amiga" -eq 1 ]]; then
        wk1_icon_to_copy="$(hst_icon_update_file "$work_icon" "1" "$wk1_ix" "$wk1_iy" "$wk1_dx" "$wk1_dy" "$wk1_dw" "$wk1_dh")"
      fi
      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"
      if [[ "$use_hst_amiga" -eq 1 ]]; then
        sd0_icon_to_copy="$(hst_icon_update_file "$sd0_icon" "1" "$sd0_ix" "$sd0_iy" "$sd0_dx" "$sd0_dy" "$sd0_dw" "$sd0_dh")"
      fi
      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" ]]; 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="$IMG/mbr/2/rdb/${PART_PNS[$_pi]}"
        if ! "$HST_IMAGER" fs dir "$part_root" >/dev/null 2>&1; 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"
        if [[ "$use_hst_amiga" -eq 1 ]]; then
          work_icon_to_copy="$(hst_icon_update_file "$work_icon" "1" "$wk_ix" "$wk_iy" "$wk_dx" "$wk_dy" "$wk_dw" "$wk_dh")"
        fi
        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"

  # Only act if the Transfer drawer exists in this partition.
  if ! "$HST_IMAGER" fs dir "$transfer_root/Transfer" >/dev/null 2>&1; then
    return 0
  fi

  # Ensure the Transfer drawer is visible on Workbench (requires an .info icon).
  if "$HST_IMAGER" fs dir "$SYS_ROOT/Prefs/Env-Archive/Sys/def_drawer.info" >/dev/null 2>&1; then
    "$HST_IMAGER" fs copy "$SYS_ROOT/Prefs/Env-Archive/Sys/def_drawer.info" "$transfer_root/Transfer.info" --force >/dev/null 2>&1 || true
  elif "$HST_IMAGER" fs dir "$SYS_ROOT/Prefs/Env-Archive/Sys/Def_Drawer.info" >/dev/null 2>&1; then
    "$HST_IMAGER" fs copy "$SYS_ROOT/Prefs/Env-Archive/Sys/Def_Drawer.info" "$transfer_root/Transfer.info" --force >/dev/null 2>&1 || true
  elif "$HST_IMAGER" fs dir "$SYS_ROOT/Tools.info" >/dev/null 2>&1; then
    "$HST_IMAGER" fs copy "$SYS_ROOT/Tools.info" "$transfer_root/Transfer.info" --force >/dev/null 2>&1 || true
  elif "$HST_IMAGER" fs dir "$SYS_ROOT/System.info" >/dev/null 2>&1; then
    "$HST_IMAGER" fs copy "$SYS_ROOT/System.info" "$transfer_root/Transfer.info" --force >/dev/null 2>&1 || true
  elif "$HST_IMAGER" fs dir "$SYS_ROOT/Prefs.info" >/dev/null 2>&1; then
    "$HST_IMAGER" fs copy "$SYS_ROOT/Prefs.info" "$transfer_root/Transfer.info" --force >/dev/null 2>&1 || true
  fi
}

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 ! "$HST_IMAGER" fs dir "$drawer_path" >/dev/null 2>&1; then
    return 0
  fi
  if "$HST_IMAGER" fs dir "$drawer_path.info" >/dev/null 2>&1; 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 "$HST_IMAGER" fs dir "$cand" >/dev/null 2>&1; then
      icon_src="$cand"
      break
    fi
  done

  if [[ -n "$icon_src" ]]; then
    "$HST_IMAGER" fs copy "$icon_src" "$drawer_path.info" --force >/dev/null 2>&1 || true
  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" != "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" != "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 "$HST_IMAGER" fs dir "$sys_root/L/fat95" >/dev/null 2>&1; 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:-}"

  _RTG_BACKDROP_PNG_INSTALLED=0

  case "$wb_version" in 3.2*) ;; *) return 0 ;; esac
  [[ -z "$screen_width" || -z "$screen_height" ]] && return 0
  [[ -z "$adf_src" || ! -d "$adf_src" ]] && return 0

  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
    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

  "$AMIGA_TOOLS" patch-wbpattern "$_tmp" "$backdrop_path"

  if [[ $? -ne 0 ]]; 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 ADF basename 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
  adf_basename="$(basename "${WORKBENCH_ADF:-}" 2>/dev/null || true)"
  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 "$HST_IMAGER" fs dir "$sys_root/S/StartWBDock" >/dev/null 2>&1; 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"
    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 ! "$HST_IMAGER" fs dir "$sys_root/S/StartWBDock" >/dev/null 2>&1; 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"
    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
}
