我想在 Mac 上玩《星露谷物语》,出门时又能接着用 iPhone 玩。实际用的是 macOS Steam 版和 iOS Apple Arcade 版。我把同一个农场文件夹在两端互相拷贝,已经验证这组环境可以读取;这不代表所有游戏版本、平台组合或 Mod 存档都兼容。本文记录我最终跑通的流程,以及快捷指令给无扩展名文件自动补 .xml 的坑。

先说清楚“同步”的含义:这里用 iCloud Drive 当中转站。iPhone 端点一下快捷指令,把整个农场文件夹打成 ZIP;Mac 端运行脚本接收或导出;从 Mac 回到 iPhone 时,还要在“文件”App 中手动解压和恢复。它不是后台实时同步,也不会自动判断哪一端的游戏进度更新。

先认准存档和中转目录

本文用 Farmer_123456789 作为示例农场名。你需要换成自己在“文件”App 和 Mac 上看到的完整文件夹名,不要把这个示例名当成通用存档名。

位置路径或文件夹
iPhone 本机存档“文件”App →“浏览”→“我的 iPhone”→Stardew Valley→Farmer_123456789
Mac Steam 默认存档~/.config/StardewValley/Saves/Farmer_123456789
两端交接区iCloud Drive → Stardewbackup

在 Mac 的 Finder 中按 Shift + Command + G,粘贴 ~/.config/StardewValley/Saves 就能打开默认目录。农场文件夹里至少要有两个没有文件扩展名的文件:与文件夹同名的 Farmer_123456789 和 SaveGameInfo。还可能看到 Farmer_123456789_old、SaveGameInfo_old;我自己的存档还出现过 _SVBAK、_SVEMERG,不能把它们当成每位玩家必有的文件。存档的内容虽然是 XML,文件名本身仍不应被额外加上 .xml。Stardew Valley Wiki 的存档说明也列出了两端位置、必需文件及版本兼容边界。

第一次操作前,先在两端各自留一份完整农场文件夹备份,放在游戏存档目录以外。游戏里先睡觉,等隔天保存完成,再完全退出游戏。不要在两端同时推进同一个农场。

iPhone → Mac:快捷指令上传整个文件夹

我在 iPhone 上建了一个“上传星露谷存档”快捷指令,动作顺序如下:

  1. 用“获取文件夹内容”读取“我的 iPhone”里的 Stardew Valley 父目录,关闭递归读取。
  2. 对结果做筛选:名称等于自己的农场文件夹名,例如 Farmer_123456789。确认筛出来的是那个文件夹,而不是文件夹里的某个文件。
  3. 将筛出的文件夹本身直接交给“制作 ZIP 归档”。不要再接一个“获取文件夹内容”去选里面的文件。
  4. 用“保存文件”把 ZIP 放到 iCloud Drive 的 Stardewbackup 文件夹,文件名固定为 Farmer_123456789.zip。确认路径后可以关闭“询问存储位置”,以后点一次即可上传。

固定名称意味着下一次上传会更新中转 ZIP;它不是历史备份。每次覆盖前,保留好两端的旧农场备份,并确认刚才保存的是自己想继续的游戏进度。等 iCloud 上传完成、Mac Finder 里这个 ZIP 已下载到本地,再继续接收。仅凭文件修改时间,无法判断哪个存档的游戏进度更靠后。

这一步最容易踩坑:我试过先“获取文件夹内容”取出六个单独文件,再制作 ZIP,结果无扩展名的文件被加上 .xml。另一次用快捷指令“解压缩归档 → 保存文件”恢复,也遇到了同样问题。把整个农场文件夹交给 ZIP 动作后,Mac 查看的压缩包仍是无后缀文件;iPhone“文件”App 原生压缩、解压整个文件夹也能保留文件名。因此不要用上述单文件快捷指令链路来恢复这份存档,更不要在没核对内容时批量改后缀。

Mac 接收:先检查,再备份替换

下文公开 01-Receive-from-iPhone.command、02-Send-to-iPhone.command 和共用的 exchange.sh 三个完整文件。把它们保存在 Mac 同一文件夹中,不要只复制两个 .command 入口。公开版每次运行都会要求输入完整农场文件夹名,例如 Farmer_123456789;输入后,主存档白名单、ZIP 根目录及交接文件名会一起跟着变化。它只接受以 _数字 结尾,且不含路径分隔符、通配符和控制字符的名称。

在 Mac 上双击 01-Receive-from-iPhone.command,选择手机上传的 Farmer_123456789.zip。脚本要求 ZIP 内只有一个 Farmer_123456789/ 根文件夹,里面至少有同名主存档和 SaveGameInfo,拒绝未知文件、.xml 后缀、链接、重复条目及异常路径。它会显示来源和替换目标,输入 YES 后,先把 Mac 旧农场完整复制到 ~/Library/Application Support/Stardew Save Exchange/Backups 并校验,再替换 ~/.config/StardewValley/Saves/Farmer_123456789。检测到游戏或 SMAPI 进程时会停止;中断后要先查看终端提示的备份、暂存与锁目录,不要直接反复重试。

如果手动操作,先在 Finder 中检查 ZIP:解压后应只有一层 Farmer_123456789/,进入后是无 .xml 后缀的存档文件。退出游戏,把 Mac 现有的整个同名农场文件夹复制到游戏目录外的备份位置,再把从手机解压出的整个文件夹放入 Saves。不要把文件直接散放在 Saves,也不要多套一层 Farmer_123456789/Farmer_123456789/。第一次在 Mac 载入后核对游戏日期、物品与进度,再继续游玩。

Mac → iPhone:导出后用“文件”App 恢复

Mac 上睡觉保存并退出游戏后,双击 02-Send-to-iPhone.command。在选择框里明确选中 iCloud Drive 中的 Stardewbackup 文件夹,再输入 YES。脚本会复制 Mac 农场、校验复制结果,把整个文件夹打包并再次核对 ZIP,随后生成 Mac-to-iPhone-Farmer_123456789.zip。它不会覆盖手机上传的 Farmer_123456789.zip;旧交接包和本次导出包的副本保存在 Mac 的 ~/Library/Application Support/Stardew Save Exchange/Backups。脚本只检查所选文件夹的名称,无法证明你选中的位置真的在 iCloud Drive,所以选择时仍要自己核对。

等 Mac 上传完毕、iPhone 也下载完成后,在 iPhone“文件”App 中进入 iCloud Drive → Stardewbackup,把 Mac 导出的 ZIP 放到一个独立空文件夹,再点 ZIP 用“文件”App 原生方式解压。Apple 的 ZIP 操作说明给出了“文件”App 的压缩与解压入口。打开解压结果,确认只有一层农场目录,里面主存档与 SaveGameInfo 没有被补上 .xml。

随后完全退出 iPhone 游戏,在“我的 iPhone”→Stardew Valley 先备份原农场,再用解压后的同名农场文件夹替换。若“文件”App 因同名自动加编号,先移开旧文件夹,再把待恢复的文件夹放到正确层级并核对名称;不要把带编号的文件夹直接留给游戏。打开游戏后,先确认日期、物品和进度与 Mac 端相符。

没有脚本也能做 Mac → iPhone:在 Finder 中复制 Mac 的整个农场文件夹到临时位置,压缩这个文件夹,命名为 Mac-to-iPhone-Farmer_123456789.zip,放进 iCloud Drive 的 Stardewbackup。检查 ZIP 只有一层农场目录,并在 iPhone 按上面的原生“文件”App 步骤恢复。手动操作更要保留原文件夹备份。

冲突与适用范围

  • 每次换设备都遵守“游戏内睡觉保存 → 完全退出 → 备份 → 交接 ZIP 同步完成 → 核对目标进度 → 替换”的顺序。iCloud Drive 只负责传文件,不负责解决两个不同游戏进度的冲突。
  • 如果 Steam Cloud 也提示冲突,先比对游戏内日期和实际进度,保留两端副本后再决定;不要只按修改时间选“较新”的文件。
  • 不建议把“关闭 App 时自动覆盖存档”作为方案。切到别的 App 也可能触发关闭类自动化,而且它不能保证游戏已经完成保存。
  • 本文的互拷结果来自我使用的 iOS Apple Arcade 版与 macOS Steam 版;Mac 脚本的目录、命名及文件检查也按这份实测存档设计。不同版本、Mod、自定义存档路径或别的农场文件结构,需要先在副本中自行验证。特别是接收端游戏版本较旧时,可能无法载入由新版本保存的存档。

这套方法最终保留了一点必要的手动确认。对存档来说,能看清来源、备份和目标进度,比悄悄自动覆盖更重要。

三个 Mac 脚本:完整代码与运行方法

下面的代码就是公开版的全部依赖。分别新建对应文件,复制各自代码块的完整内容,统一保存为 UTF-8 文本,放在同一个本地文件夹中;代码块外的文件名不要写进文件内容。exchange.sh 必须和两个 .command 位于同一级目录。三份代码经 macOS Bash 语法检查;我还用临时虚构存档测试了接收、导出、拒绝异常 ZIP 和失败回滚,没有用真实游戏存档测试公开版。

首次使用时,确认 Mac Steam 存档确实位于 ~/.config/StardewValley/Saves/;脚本不会猜测其他安装位置。iPhone 的上传 ZIP 必须叫 <农场文件夹名>.zip,内部只有一层同名农场目录。先在游戏内睡觉保存、完全退出两端游戏,等 iCloud 同步完成。运行入口时输入文件夹的完整名称,不要加 .zip;看清文件选择框的来源或目的地,最后输入 YES 才会继续。导出时自己确认所选 Stardewbackup 确实在 iCloud Drive 中;脚本只能核对文件夹名字。若 macOS 解压后没有给 .command 执行权限,可以在“终端”输入 /bin/bash (末尾一个空格),再把入口文件拖入窗口并回车。遇到系统安全提示时先检查来源,不要关闭系统防护。

01-Receive-from-iPhone.command:选择手机下载好的 ZIP,备份 Mac 旧农场后接收。

#!/bin/bash
HERE="$(cd "$(dirname "$0")" && pwd)"
/bin/bash "$HERE/exchange.sh" "receive"
status=$?
printf '\n按回车关闭(退出码 %s)…' "$status"
read -r ignored
exit "$status"

02-Send-to-iPhone.command:选择 iCloud Drive 中的 Stardewbackup,导出供手机恢复的 ZIP。

#!/bin/bash
HERE="$(cd "$(dirname "$0")" && pwd)"
/bin/bash "$HERE/exchange.sh" "upload"
status=$?
printf '\n按回车关闭(退出码 %s)…' "$status"
read -r ignored
exit "$status"

exchange.sh:两个入口共同调用的校验、备份和交换逻辑。

#!/bin/bash
# macOS Bash 3.2 compatible. No Python or third-party dependencies.
set -euo pipefail
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
export LC_ALL=C
umask 077
FARM=''
FILES=()
fail() { printf '\n停止:%s\n' "$*" >&2; return 1; }
select_farm() {
  local input
  printf '请输入农场文件夹的完整名称(例如 Farmer_123456789):'
  IFS= read -r input || { fail '未输入农场名称。'; return 1; }
  # ZIP tools interpret glob characters; reject them instead of guessing which save was selected.
  case "$input" in ''|.*|-*|*/*|*'\'*|*'*'*|*'?'*|*'['*|*']'*|*'|'*|*':'*)
    fail '农场名称包含不支持的路径或通配符字符。'; return 1;;
  esac
  if ! [[ "$input" =~ ^[^[:cntrl:]]+_[0-9]+$ ]]; then
    fail '请输入以“_数字”结尾的完整农场文件夹名。'; return 1
  fi
  FARM=$input
  FILES=("$FARM" "${FARM}_old" "${FARM}_SVBAK" "${FARM}_SVEMERG" SaveGameInfo SaveGameInfo_old)
}
allowed() { local x; for x in "${FILES[@]}"; do [ "$1" != "$x" ] || return 0; done; return 1; }
check_closed() {
  if pgrep -if '[S]tardewValley|[S]tardew Valley|[S]tardewModdingAPI' >/dev/null; then
    fail '检测到游戏或 SMAPI 进程。请完全退出后重试。'; return 1
  fi
}
confirm() { local answer; printf '\n%s\n输入 YES 并回车继续,其他输入取消:' "$1"; read -r answer; [ "$answer" = YES ] || { fail '已取消,未替换存档。'; return 1; }; }
validate_folder() {
  local folder="$1" p name count=0
  [ -d "$folder" ] && [ ! -L "$folder" ] || { fail '存档目录不存在或是符号链接。'; return 1; }
  while IFS= read -r -d '' p; do
    name=${p##*/}
    allowed "$name" && [ -f "$p" ] && [ ! -L "$p" ] && [ -s "$p" ] || { fail "不接受这个文件/子目录:$name"; return 1; }
    count=$((count+1))
  done < <(find "$folder" -mindepth 1 -maxdepth 1 -print0)
  [ "$count" -ge 2 ] && [ -s "$folder/$FARM" ] && [ -s "$folder/SaveGameInfo" ] || { fail '缺少主存档或 SaveGameInfo。'; return 1; }
}
# Never extract archive paths. Stream only allowlisted entries into fixed local filenames.
# Thus even a malicious local ZIP filename cannot write outside the staging directory.
read_zip() {
  local archive="$1" dest="$2" list="$3" entry name seen='|' count=0
  [ -f "$archive" ] && [ ! -L "$archive" ] || { fail '请选择已经下载完成的普通 ZIP 文件。'; return 1; }
  zipinfo -1 "$archive" > "$list" || return 1
  # Reject Unix symlinks and special files. No actual ZIP extraction is used.
  if zipinfo -l "$archive" | grep -E '^[lbcps]' >/dev/null; then fail 'ZIP 包含链接或特殊文件。'; return 1; fi
  local bytes
  bytes=$(unzip -l "$archive" | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+[[:space:]]+files?$/ {print $1}')
  [ -n "$bytes" ] && [ "$bytes" -le 268435456 ] || { fail 'ZIP 大小异常(上限 256 MiB)。'; return 1; }
  unzip -tqq "$archive" </dev/null || { fail 'ZIP 不完整、已损坏或需要密码。'; return 1; }
  while IFS= read -r entry || [ -n "$entry" ]; do
    case "$seen" in *"|$entry|"*) fail 'ZIP 含重复条目。'; return 1;; esac
    seen="$seen$entry|"
    [ "$entry" != "$FARM/" ] || continue
    case "$entry" in "$FARM/"*) name=${entry#"$FARM/"};; *) fail "ZIP 须只有一个 $FARM 根文件夹。"; return 1;; esac
    allowed "$name" || { fail "未知文件名(不接受 .xml):$entry"; return 1; }
    count=$((count+1))
  done < "$list"
  [ "$count" -ge 2 ] && [ "$count" -le 6 ] || { fail 'ZIP 文件数量不符合预期。'; return 1; }
  mkdir "$dest"
  while IFS= read -r entry; do
    [ "$entry" != "$FARM/" ] || continue
    name=${entry#"$FARM/"}
    unzip -p "$archive" "$entry" > "$dest/$name" || return 1
  done < "$list"
  validate_folder "$dest"
}
choose_zip() { osascript -e 'POSIX path of (choose file with prompt "选择手机上传的农场 ZIP(请先在 Finder 下载完成)")'; }
choose_destination() { osascript -e 'POSIX path of (choose folder with prompt "选择 iCloud Drive 中的 Stardewbackup 文件夹(不要选择游戏 Saves)")'; }
cleanup() {
  local status=$? restored=0
  trap - EXIT INT TERM HUP
  if [ "${MOVED:-0}" = 1 ] && [ -d "$WORK/previous" ]; then
    printf '\n尝试还原替换前的存档……\n' >&2
    if [ -e "$TARGET" ]; then mv "$TARGET" "$WORK/failed-import" || true; fi
    if [ ! -e "$TARGET" ] && mv "$WORK/previous" "$TARGET"; then restored=1; fi
    printf '请检查存档;完整备份:%s\n' "$BACKUP" >&2
    printf '暂存目录保留在:%s\n' "$WORK" >&2
  elif [ -n "${WORK:-}" ] && [ -d "$WORK" ]; then
    rm -rf "$WORK"
  fi
  if [ -n "${LOCK:-}" ] && [ "${LOCKED:-0}" = 1 ]; then
    if [ "${MOVED:-0}" != 1 ] || [ "$restored" = 1 ]; then rm -rf "$LOCK"; fi
  fi
  exit "$status"
}
main() {
  [ "$(uname -s)" = Darwin ] || { fail '此工具只在 macOS 上运行。'; return 1; }
  local mode="$1" source dest stamp export_dir
  select_farm
  SAVES="$HOME/.config/StardewValley/Saves"
  TARGET="$SAVES/$FARM"
  BACKUPS="$HOME/Library/Application Support/Stardew Save Exchange/Backups"
  printf '星露谷存档交换 • %s\n云端同步不是实时同步;文件修改时间不能证明游戏进度较新。\n' "$FARM"
  check_closed
  [ -d "$SAVES" ] && [ ! -L "$SAVES" ] || { fail '找不到标准 Steam Saves 目录,或该目录是链接。不自动猜测其他位置。'; return 1; }
  [ "$(cd "$SAVES" && pwd -P)" = "$SAVES" ] || { fail 'Saves 路径包含符号链接,请先确认实际位置。'; return 1; }
  stamp=$(date '+%Y%m%d-%H%M%S')
  mkdir -p "$BACKUPS"
  LOCK="${SAVES%/Saves}/.save-exchange-lock"
  if ! mkdir "$LOCK" 2>/dev/null; then
    printf '另一次交换正在运行,或上次异常中断。请勿继续覆盖。检查:%s/recovery.txt\n' "$LOCK" >&2
    return 1
  fi
  LOCKED=1
  WORK=''
  MOVED=0
  trap cleanup EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
  trap 'exit 129' HUP
  WORK=$(mktemp -d "${SAVES%/Saves}/.exchange-XXXXXXXX")
  printf '目标:%s\n暂存:%s\n备份目录:%s\n如已无交换进程,请先恢复 previous 或旧备份,再移除此锁目录。\n' "$TARGET" "$WORK" "$BACKUPS" > "$LOCK/recovery.txt"
  if [ "$mode" = receive ]; then
    source=$(choose_zip) || return 1
    [ "${source##*/}" = "$FARM.zip" ] || { fail "手机输入包必须名为 $FARM.zip,以免误选电脑导出包。"; return 1; }
    [ -f "$source" ] && [ ! -L "$source" ] || { fail '输入必须是普通 ZIP 文件,不接受链接。'; return 1; }
    cp "$source" "$WORK/phone.zip"
    read_zip "$WORK/phone.zip" "$WORK/$FARM" "$WORK/entries"
    printf '\n手机输入:%s\n将替换:%s\n' "$source" "$TARGET"
    confirm '确认两端游戏已保存并退出,iCloud 已下载完成,而且这个手机包就是你要继续的进度。旧电脑存档会先备份。'
    check_closed
    if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then
      [ -d "$TARGET" ] && [ ! -L "$TARGET" ] || { fail '目标不是普通文件夹。'; return 1; }
      BACKUP=$(mktemp -d "$BACKUPS/before-phone-$stamp-XXXXXXXX")
      cp -pR "$TARGET" "$BACKUP/$FARM"
      diff -qr "$TARGET" "$BACKUP/$FARM" >/dev/null || { fail '旧存档备份校验失败。'; return 1; }
      check_closed
      printf '本次旧存档备份:%s\n' "$BACKUP" >> "$LOCK/recovery.txt"
      MOVED=1
      mv "$TARGET" "$WORK/previous"
    fi
    check_closed
    mv "$WORK/$FARM" "$TARGET"
    validate_folder "$TARGET"
    MOVED=0
    printf '\n已接收手机存档。\n'
    [ -z "${BACKUP:-}" ] || printf '旧电脑存档完整备份:%s\n' "$BACKUP"
  else
    validate_folder "$TARGET"
    dest=$(choose_destination) || return 1
    dest=${dest%/}
    [ "${dest##*/}" = Stardewbackup ] || { fail '请选择名为 Stardewbackup 的交换文件夹。'; return 1; }
    dest=$(cd "$dest" && pwd -P)
    case "$dest/" in "$SAVES/"* ) fail '交换目录不能位于游戏 Saves 内。'; return 1;; esac
    printf '\n输出目录:%s\n' "$dest"
    confirm "确认两端游戏已保存并退出,并且所选文件夹确实位于你的 iCloud Drive。将更新 Mac-to-iPhone-$FARM.zip,手机上传包不会被改动。"
    check_closed
    cp -pR "$TARGET" "$WORK/$FARM"
    validate_folder "$WORK/$FARM"
    diff -qr "$TARGET" "$WORK/$FARM" >/dev/null || { fail '复制时存档发生变化,请退出游戏后重试。'; return 1; }
    (cd "$WORK" && zip -q -r "$WORK/export.zip" "$FARM")
    read_zip "$WORK/export.zip" "$WORK/verify" "$WORK/entries"
    diff -qr "$WORK/$FARM" "$WORK/verify" >/dev/null || { fail '导出校验失败。'; return 1; }
    export_dir=$(mktemp -d "$BACKUPS/mac-export-$stamp-XXXXXXXX")
    cp "$WORK/export.zip" "$export_dir/Mac-to-iPhone-$FARM.zip"
    cmp -s "$WORK/export.zip" "$export_dir/Mac-to-iPhone-$FARM.zip" || { fail '本地导出备份校验失败。'; return 1; }
    local handoff="$dest/Mac-to-iPhone-$FARM.zip" temp
    [ ! -L "$handoff" ] && { [ ! -e "$handoff" ] || [ -f "$handoff" ]; } || { fail '交接文件位置不是普通文件。'; return 1; }
    if [ -f "$handoff" ]; then
      cp "$handoff" "$export_dir/previous-handoff.zip"
      cmp -s "$handoff" "$export_dir/previous-handoff.zip" || { fail '旧交接包备份校验失败。'; return 1; }
    fi
    temp=$(mktemp "$dest/.mac-export-XXXXXXXX")
    if ! cp "$WORK/export.zip" "$temp" || ! cmp -s "$WORK/export.zip" "$temp" || ! mv -f "$temp" "$handoff"; then
      rm -f "$temp"; fail '写入交换目录失败;本地历史备份仍然保留。'; return 1
    fi
    printf '\n已生成:%s\n本地历史备份:%s\n等待 iCloud 上传完成,再到手机「文件」App 解压并手动恢复。\n' "$handoff" "$export_dir"
  fi
  printf '\n不要同时在两台设备继续同一存档;首次载入请核对游戏日期和进度。\n'
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
  case "${1:-}" in receive|upload) main "$1";; *) fail '请双击两个 .command 文件之一。'; exit 1;; esac
fi

备份保存在 ~/Library/Application Support/Stardew Save Exchange/Backups,不会自动清理;请定期检查占用空间。遇到异常中断,先查看终端给出的暂存、备份路径,以及 ~/.config/StardewValley/.save-exchange-lock/recovery.txt,确认恢复状态后再处理锁,不要直接删锁重试。Mac 导出的 ZIP 到 iPhone 后,仍须按上文用“文件”App 原生解压、备份旧农场并手动恢复。

最后修改:2026 年 10 月 06 日
感谢您的支持