教學 › 備份與還原 SOP

備份與還原 SOP

本站三個承諾的第二條是「一定先教你備份」。這頁把那句話展開成一份可以照著做的流程。 先講一個量出來的結論:最常被動到的那四個遊戲檔,加上暖身課請你打開的 controller.cfg,一共五個, 合計 4,058,641 個位元組(3.87 MB),大約是遊戲 data 資料夾的五百分之一,備份它們比你想像的快很多。 但那五個不是全部。本站有 39 課,其中 30 課會寫進遊戲檔(2026-09-05 數的); 核心五檔涵蓋其中 10 課,另外 20 課動的是別的檔(models.bigdatafile.bigmvp2005.exelogos.big、開場動畫 …)。第二節有逐課的完整清單,上到哪一課就照那一列多備份哪一個檔。

難度

★☆☆ 只是複製貼上

時間

第一次約 10 分鐘

要裝 Python 嗎

備份不用。只有檢查腳本要

🛟 動手之前:先完整備份整個遊戲資料夾

把整個遊戲資料夾複製一份到別的地方包括裡面存放紀錄的資料夾。不要挑檔案,整包複製最省事也最保險。

「紀錄」是指這些(它們跟遊戲檔混在同一個資料夾裡):

dir /s /b "〔你的遊戲資料夾〕\*.sav"
find "〔你的遊戲資料夾〕" -name "*.sav"

上面第一行 Windows、第二行 Mac。 本站每一支工具都會自己備份它要動的那一個檔, 但那只保得住那一個檔 —— 整包備份保的是「你玩到現在的一切」。 完整做法就是你正在看的這一頁,往下讀就是。

⚡ 只想趕快做完?照這四步

  1. 在桌面建一個帶日期的備份資料夾,裡面先開好兩層子資料夾。
    Windows
    mkdir "%USERPROFILE%\Desktop\MVP-backup-20260825\data\database"
      mkdir "%USERPROFILE%\Desktop\MVP-backup-20260825\data\frontend"
    Mac
    mkdir -p ~/Desktop/MVP-backup-20260825/data/database ~/Desktop/MVP-backup-20260825/data/frontend
  2. 把五個檔複製進去,合計只有 3.87 MB。
    Windows
    copy "你的遊戲資料夾\controller.cfg" "%USERPROFILE%\Desktop\MVP-backup-20260825\"
      copy "你的遊戲資料夾\data\database\attrib.dat" "%USERPROFILE%\Desktop\MVP-backup-20260825\data\database\"
      copy "你的遊戲資料夾\data\database\pitcher.dat" "%USERPROFILE%\Desktop\MVP-backup-20260825\data\database\"
      copy "你的遊戲資料夾\data\database\schedule.big" "%USERPROFILE%\Desktop\MVP-backup-20260825\data\database\"
      copy "你的遊戲資料夾\data\frontend\ingame.big" "%USERPROFILE%\Desktop\MVP-backup-20260825\data\frontend\"
    Mac
    cd "你的遊戲資料夾"
      cp controller.cfg ~/Desktop/MVP-backup-20260825/
      cp data/database/attrib.dat data/database/pitcher.dat data/database/schedule.big ~/Desktop/MVP-backup-20260825/data/database/
      cp data/frontend/ingame.big ~/Desktop/MVP-backup-20260825/data/frontend/
  3. 下載這頁的檢查腳本、拖到桌面,把遊戲資料夾跟備份資料夾一起交給它,五行都要顯示「內容一致」。
    Windows
    python mvp_check_backup.py "你的遊戲資料夾" "%USERPROFILE%\Desktop\MVP-backup-20260825"
    Mac
    python3 mvp_check_backup.py "你的遊戲資料夾" ~/Desktop/MVP-backup-20260825
  4. 要還原:先試那一課工具的 --restore,不行就把備份複製回去覆蓋(先把遊戲關掉)。以還原 ingame.big 為例,其他檔同理換路徑。
    Windows
    copy /Y "%USERPROFILE%\Desktop\MVP-backup-20260825\data\frontend\ingame.big" "你的遊戲資料夾\data\frontend\"
    Mac
    cp ~/Desktop/MVP-backup-20260825/data/frontend/ingame.big "你的遊戲資料夾"/data/frontend/

指令裡的 你的遊戲資料夾 整段換成你自己的路徑,日期換成你今天的。 這五個檔只涵蓋 10 課:要上改視角、中文版修正、裝球衣、換臉皮、換大頭照、 換開機畫面、換聲音、球場瘦身、關掉開場動畫、改主選單文字、換球隊隊徽、改執行檔這些課,還要多備份別的檔, 清單在下面第二節的第二張表。 想知道為什麼是這五個、為什麼工具自己那份備份只夠退一步,往下讀; 只想複習,跳到📌 重點整理

一 · 為什麼要備份

附腳本的那幾課都會自動幫你備份,這是真的。但那份自動備份不是你的備份: 它是「一鍵還原」的燃料,只夠退回一步,而且就躺在原檔旁邊, 跟原檔一起被刪、一起被搬走,也可能被下一個模組蓋掉。

工具的備份只夠退一步

本站二十六支會改檔的腳本,每一支都只在第一次 --apply 時建立備份, 之後再改幾次都不覆蓋它。二十六支的原始碼裡是同一個寫法:備份檔已經存在就跳過 (「封裝檔可以拆成散裝嗎」那一支是把原檔改名,備份名被佔住就停手,效果一樣)。 這個設計是對的,它保住了你動手之前的那一份。但它只有一份, 而且如果你先後跑過兩支動同一個檔的工具,會出現兩份內容不同的備份, 哪一份比較舊,肉眼看不出來。這頁後面會教你怎麼分辨。

還有一個理由:封裝檔改一次就長大

本站會寫 .big 封裝檔的腳本(換賽程年份、關掉跑壘者速度數值、介面萬用工具、 改記分板的顏色、換球隊隊徽、換大頭照、換開機畫面、做一張新的球員臉皮、把一套球衣裝進遊戲、一整包模組自動歸位), 寫法都是把新資料接在檔案最後面,再把目錄改成指向新位置 (原因見 BIGF 封裝格式)。 舊資料一個位元組都沒動,還躺在裡面。所以改一次,檔案就大一次。 唯一的例外是球場超過 10MB 就當機那一課: 那一支反過來把目錄沒指到的位元組清掉,所以它會讓檔案變小。 (2026-09-05 訂正:這裡原本寫「本站三支會寫 .big 的腳本」, 那是 2026-08-25 盤點時的數字,後來加的幾課同樣寫封裝檔。)

檔案怎麼寫入改前改一次之後
attrib.dat純文字,整份重寫 840,643840,6430
ingame.big封裝檔,接檔尾 2,665,5622,750,503+84,941
schedule.big封裝檔,接檔尾 130,514651,825+521,311

單位都是位元組,全部在沙盒複本上實際跑過一次量到的。 attrib.dat 那一列改動的是一位球員的跑壘速度, 檔案大小完全沒變,用 cmp -l 逐位元組比對,整份檔案只有 1 個位元組不同

同一支工具連跑三次,檔案會長三次

拿還沒動過的 schedule.big 連續換三次年份,每一次都固定多 521,311 個位元組:

還沒動過      130,514 bytes
換成 2030    651,825 bytes   (+521,311)
換成 2040  1,173,136 bytes   (+521,311)
換成 2050  1,694,447 bytes   (+521,311)

三次寫入後,工具自己的複驗每次都通過,它印出來的是 「項目數 9 個(原本 9 個)」「改寫日期 18,267 個」「未對齊日期 0 個」。 但這只驗到工具這一關,本站沒有真的開遊戲跑完一個球季, 所以「疊三次還能不能玩」這件事,這頁不給答案。

可以確定的是檔案只會長不會縮。要讓它回到原本的大小, 就是把備份蓋回去,而那份備份從頭到尾都停在 130,514。

二 · 備份哪些

下面兩張表不是挑出來的。作法是把教學資料夾裡的每一頁讀完, 再把 30 支腳本裡真正會寫入的那幾行(open(…, 'wb')os.replaceshutil.copy2)一行一行找出來對照 —— 30 支裡有 26 支會寫遊戲檔,另外 4 支(本頁的檢查腳本、 mvp_check_setup.pymvp_read_voice.pymvp_check_original.py)不寫任何遊戲檔。

本頁清單最後盤點日期:2026-09-05,當時是 39 課 40 個頁面。 新增課程的人請回來更新這兩張表,不然這頁會變成一份過期的安全網 —— 這正是 2026-08-29 那次稽核抓到的問題:清單停在 10 課,而站上已經 29 課; 2026-09-05 又抓到一次,那一次少了 10 課(其中三課動的正是核心五個檔)。 要自己數現在有幾課,在專案根目錄跑 ls -1d site/tutorials/*/ | wc -l

教學會寫入的遊戲檔工具留下的備份檔名
暖身課:打開一個遊戲的檔案 不寫(只請你用記事本打開 controller.cfg 來看) 沒有工具,你自己複製到桌面
遊戲當掉了怎麼辦 不寫任何檔
五秒看穿一個檔案 不寫任何檔
把賽程換到任何年份 data\database\schedule.bigschedule.big.bak
關掉跑壘者速度數值 data\frontend\ingame.bigingame.big.bak
同一課的手動改法 data\frontend\ingame.big 沒有腳本,那一課的步驟 1 要你自己複製一份 ingame.big.bak
介面實例集 data\frontend\ingame.bigingame.big.feltoolbak
介面萬用工具 fel_toolkit data\frontend\ingame.bigingame.big.feltoolbak
換打擊姿勢與投球姿勢 data\database\attrib.dat
data\database\pitcher.dat
attrib.dat.bak
pitcher.dat.bak
修改球員跑壘速度 data\database\attrib.datattrib.dat.speedbak
幫球員換一張臉 data\database\attrib.datattrib.dat.facebak
把一位球員改成你想要的球員 data\database\attrib.datattrib.dat.playerbak
把真實成績換算成遊戲裡的能力值 data\database\attrib.dat
data\database\pitcher.dat
attrib.dat.ratingsbak
pitcher.dat.ratingsbak
rhattrib.dat.ratingsbak
lhattrib.dat.ratingsbak
改記分板的顏色 data\frontend\ingame.bigingame.big.hudcolorbak

Mac 上路徑分隔符是 / 不是 \,檔案位置一樣。 「換打擊姿勢與投球姿勢」那一課動哪一個檔,看你改的是打擊還是投球。 「幫球員換一張臉」那一課會 535.9 MB 的 models.big, 但只是讀目錄看有哪些臉皮,從頭到尾不寫它,所以它不在備份名單上。

「同一課的手動改法」那一頁,教你把 ingame.big.bak 放在原檔旁邊, 因為那正好是 mvp_hide_runner_speed.py --restore 會去找的檔名。 這跟下一節第一條規則(備份不要留在遊戲資料夾裡)並不衝突, 它只是說明那一份是工具用的,不是你的備份,你還是要另外複製一份到外面。

教學資料夾裡目前有 39 課,其中一課(關掉跑壘者速度數值)另外附了一頁手動改法, 合起來 40 個頁面。會寫進遊戲檔的是其中 31 個頁面(30 課加那一頁)。

上面那張表涵蓋的是其中 11 個頁面(10 課加那一頁手動改法),它們加起來只動四個檔attrib.dat 被 5 課動到,ingame.big 被 4 課加上那一頁手動改法動到, pitcher.dat 被 2 課動到,schedule.big 1 課。 再加上暖身課請你打開的 controller.cfg,就是這頁那五個檔。

⚠ 剩下那 20 課動的是別的檔 —— 只備份五個檔擋不住它們

五個檔的清單能用,前提是你只上上面那張表裡的課。 只要你上了下面這張表裡的任何一課,五個檔的備份就接不住你: 它動的檔根本不在那五個裡面,第四節的檢查腳本也不會去看它。 上到哪一課,就照那一列多備份哪一個檔,作法跟第三節完全一樣,只是換路徑。

教學會寫入的遊戲檔工具留下的備份檔名
把打擊視角調成你喜歡的角度 data\datafile\datafile.big
(官方中文版是散裝的 data\datafile\datafile.txt)
datafile.big.datafilebak
datafile.txt.datafilebak
介面萬用工具--camera 那一組指令 同上(跟上一列是同一支腳本) .datafilebak
中文版配大球場就跳出 mvp2005.exe
data\fonts\fonts.big
data\FEENG.LOC · data\IGENG.LOC
mvp2005.exe.modernizebak
fonts.big.fontbak
FEENG.LOC.locbak · IGENG.LOC.locbak
2026 年的電腦怎麼跑得起 2005 年的遊戲 mvp2005.exemvp2005.exe.modernizebak
球場超過 10MB 就當機 data\stadium\ 底下你指定的那個 .big (那個檔名).shrinkbak
把一套球衣裝進遊戲 data\models.big
data\frontend\uniforms.big
data\frontend\logos.big
models.big.unibak
uniforms.big.unibak
logos.big.unibak
球衣模組:安裝與製作入門 同上三個檔 沒有本站腳本,那一課用的是社群的 Windows 工具, 備份要你自己複製models.big 有 535.9 MB,會等很久)
做一張新的球員臉皮 data\models.bigmodels.big.facetexbak
換球員大頭照 data\frontend\portrait.bigportrait.big.portraitbak
換掉開機畫面 data\frontend\splash.big
data\frontend\sushared.big
data\frontend\suonly.big
(各自的檔名).screenbak
把應援曲指給別的球員 data\audio\chants\spch_cht.txtspch_cht.txt.chantbak
換掉遊戲的聲音 你指定的那個聲音檔(那個檔名).audiobak
封裝檔可以拆成散裝嗎 你指定的那個 .big 這一支不一樣:它把原本的 .big 改名(那個檔名).unpackbak,再把裡面的東西解成散裝檔。 原檔名那個位置會空掉,不是多一份備份
一整包模組自動歸位 看那包模組裡有什麼,可能是遊戲資料夾裡任何一個同名檔,事先列不完 (被動到的檔名).autobak
執行檔上的六個旋鈕 mvp2005.exemvp2005.exe.exepebak
關掉開場動畫 data\frontend\movies\ 底下的 .vp6 影片 (那個檔名).introbak
影片檔很大,這一課的備份不在核心五個檔旁邊, 第四節的檢查腳本看不到它
改主選單的文字 data\FEENG.LOC · data\IGENG.LOC FEENG.LOC.menutextbak
IGENG.LOC.menutextbak
換球隊隊徽 data\frontend\logos.biglogos.big.logobak
把官方中文版的中文搬進英文版 data\FEENG.LOC · data\IGENG.LOC
data\fonts\fonts.big
mvp2005.exe
FEENG.LOC.locbak · IGENG.LOC.locbak
fonts.big.fontbak
mvp2005.exe.modernizebak
把你改好的東西打包成一包 打包本身不寫。用它安裝別人的模組時, 跟上一列一樣可能是遊戲資料夾裡任何一個同名檔 (被動到的檔名).packbak
一次更新整個年度 這一課不自己寫檔,它是照順序帶你跑上面那些課, 所以動到的檔就是你跑過的那幾課動的檔 各課自己那一種

剩下的 6 課完全不寫任何檔,本頁不必為它們備份:本頁自己、 社群工具怎麼分工不用每次放光碟在 Mac 上玩 MVP 2005拆開一個語音檔怎麼看出哪些檔還沒被動過。 加上第一張表裡那 3 課唯讀的(暖身課、遊戲當掉了怎麼辦、五秒看穿一個檔案), 39 課裡唯讀的一共 9 課。

為什麼不寫入的 controller.cfg 也要備份

暖身課的成功標準寫得很明白:「你用記事本打開了原檔,而且沒有存檔」。 課程本身不要你寫入。但那一課的出錯處理裡,有一條就是 「不小心存檔了,遊戲怪怪的」。 只要一個檔會被你用記事本打開,它就有被存壞的機會,所以一起備份。

ingame.big data\frontend\ingame.big:2,665,562 bytes · 比賽中的介面 · 4 課加上一頁手動改法會寫它 2.54 MB attrib.dat data\database\attrib.dat:840,643 bytes · 球員資料 · 5 課會寫它 820.9 KB schedule.big data\database\schedule.big:271,273 bytes · 賽程表 · 1 課會寫它 264.9 KB pitcher.dat data\database\pitcher.dat:269,986 bytes · 投手資料 · 2 課會寫它 263.7 KB controller.cfg controller.cfg:11,177 bytes · 手把與鍵盤設定 · 暖身課只請你打開它,不寫入 10.9 KB 合計 4,058,641 bytes(3.87 MB)
資料來源:在一份實際裝好、也裝過中文模組的遊戲資料夾裡,對這五個檔各量一次大小。 controller.cfg 只有 11,177 個位元組,跟 ingame.big 差 238 倍, 所以它那一條在圖上幾乎畫不出來(那一條有拉到最小可見寬度,照同一把尺量應該只有 1.7 個像素,不是圖上的 2 個)。這正是重點:五個檔的份量差很多,但加起來還是很小。 換過球員名冊或賽程之後這些數字會變,量級一樣,都是幾 MB 而不是幾百 MB。 為什麼 schedule.big 這裡是 264.9 KB,第一節卻說 130,514 bytes: 量測用的這台,這個檔在本站量它之前就已經比原始的大了。130,514 是它旁邊那份 schedule.big.bak 的大小,也就是還沒動過的樣子。 兩個數字都是真的,只是取自不同時間點。 (2026-09-05 訂正:這裡原本寫「賽程已經換過年份,檔案早就長大了」。 本站的換年份工具每跑一次固定多 521,311 個位元組,而這台的 271,273 比 130,514 只多 140,759,不在那條序列上的任何一格 —— 所以它變大的原因不是本站這支工具, 本站沒有量到它到底是被什麼改的。)

五個檔 = 遊戲資料的五百分之一

同一台上量到的對照:data 資料夾整個算下來是 2,847,001,431 bytes(2.65 GB)、1,000 個檔。 其中有一個是使用者自己留的球場備份子資料夾,佔 679,016,185 bytes(648 MB)、88 個檔; 把它扣掉,data 還有 2,167,985,246 bytes(2.02 GB)、912 個檔

要備份的那五個檔裡,有四個在 data 底下(controller.cfg 在遊戲根目錄,另外算),這四個佔扣掉之後的 0.19%(約 1/534); 連那個備份夾一起算則是 0.14%(約 1/701)。 這頁採用比較保守的 0.19%,不拿使用者自己的備份夾把比例灌得更好看。

換個講法更有感:光是 models.big 一個檔 (561,891,312 bytes,535.9 MB),就是這五個檔加起來的 138 倍。 備份整個遊戲要等很久又佔空間,備份這五個檔幾秒鐘就好

那如果我裝了別人做的模組呢

那就不只這五個檔。這張表涵蓋的是「你照本站教學自己改」的範圍。 別人做的模組可能換掉球衣、球場、大頭照、語音,動到的檔案完全是另一批, 本站沒有實際量過任何一個社群模組動到哪些檔。 裝第三方模組之前,最保險的做法是把整個 data 資料夾複製一份, 慢歸慢,但那是你唯一的退路。

三 · 備份放哪

三條規則,順序不能換:

1
在桌面建一個帶日期的備份資料夾

名字用英數字、不要用中文,等一下的指令碰到中文資料夾名比較容易出錯。

WWindows:在桌面空白處按右鍵 → 新增 → 資料夾, 命名為 MVP-backup-20260825(日期換成今天)。

MMac:在桌面空白處按右鍵(或按住 Control 點一下) → 新增檔案夾,同樣命名。

再在裡面建兩層子資料夾:data\databasedata\frontend。 用指令一次建好比較快:

Windows(貼到命令提示字元)

mkdir "%USERPROFILE%\Desktop\MVP-backup-20260825\data\database"
mkdir "%USERPROFILE%\Desktop\MVP-backup-20260825\data\frontend"

Mac(貼到終端機)

mkdir -p ~/Desktop/MVP-backup-20260825/data/database ~/Desktop/MVP-backup-20260825/data/frontend
如果那兩行報錯

用滑鼠建也一樣:進備份資料夾建一個 data, 進 data 再建 databasefrontend

2
把五個檔複製進去

滑鼠複製貼上也完全可以。下面的指令只是比較不會漏檔。

Windows:把指令裡的 你的遊戲資料夾 整段換成你自己的路徑(用拖曳取得,見第 0 課操作二

copy "你的遊戲資料夾\controller.cfg" "%USERPROFILE%\Desktop\MVP-backup-20260825\"
copy "你的遊戲資料夾\data\database\attrib.dat" "%USERPROFILE%\Desktop\MVP-backup-20260825\data\database\"
copy "你的遊戲資料夾\data\database\pitcher.dat" "%USERPROFILE%\Desktop\MVP-backup-20260825\data\database\"
copy "你的遊戲資料夾\data\database\schedule.big" "%USERPROFILE%\Desktop\MVP-backup-20260825\data\database\"
copy "你的遊戲資料夾\data\frontend\ingame.big" "%USERPROFILE%\Desktop\MVP-backup-20260825\data\frontend\"

Mac

cd "你的遊戲資料夾"
cp controller.cfg ~/Desktop/MVP-backup-20260825/
cp data/database/attrib.dat data/database/pitcher.dat data/database/schedule.big ~/Desktop/MVP-backup-20260825/data/database/
cp data/frontend/ingame.big ~/Desktop/MVP-backup-20260825/data/frontend/
你會看到

Mac 什麼都不印,沒有訊息就是成功,這是它的習慣。 Windows 每一行會回一句表示複製了 1 個檔案的訊息,共五句 (確切用字看你的 Windows 版本,本站沒有逐版本比對過)。

如果沒看到
  • Windows 印「系統找不到指定的路徑」→ 路徑打錯,或是引號漏了。 路徑裡有空格(例如 Program Files)時,前後一定要各有一個半形雙引號。
  • Mac 印 No such file or directory → 同上,先確認 cd 那一行有沒有成功。
  • 印「拒絕存取」/ Permission denied → 遊戲裝在 C:\Program Files 底下,改用系統管理員身分開命令提示字元再試一次。

四 · 怎麼確認備份成功

「複製過去了」跟「複製對了」是兩件事。下面這支腳本會把兩件事一次講清楚: 該備份的檔在不在、多大、什麼時候改的、有沒有備份、你的備份跟現在的檔一不一樣。

⬇ 下載檢查腳本 mvp_check_backup.py

按了下載之後,檔案跑到哪裡去了?

瀏覽器多半不會問你要存哪裡,它會直接放進「下載」資料夾。 開檔案總管(Mac 用 Finder)左邊點「下載」,找到那個 .py拖到桌面再往下做。

沒做這一步的話,後面 cd 到桌面再執行, 畫面會出現 can't open file —— 那不是你打錯。

933 行、零相依、檢查你的遊戲資料夾與備份資料夾時全程唯讀,不會在裡面建立、修改或刪除任何檔案、附完整原始碼。 唯一會寫檔的是它的自我測試(--selftest):那些檔建在系統暫存資料夾裡,跑完自己刪掉,不碰你的遊戲。 掃到一半想停就按 Ctrl-C:它會回一句「已中止」再停(結束碼 130),你的遊戲資料夾與備份資料夾一個位元組都沒有動到。 掃描途中外接碟斷線、檔案被防毒鎖住這一類,它也會講一句人看得懂的話(結束碼 2),不會丟一串 Python 錯誤訊息給你自己猜。 需要 Python 3.7 以上(見第 0 課)。腳本採 MIT 授權(本站教學文字另採 CC BY 4.0)。

1
只看遊戲資料夾

先確認該備份的五個檔都在、以及遊戲資料夾裡目前有哪些工具留下的備份。

python mvp_check_backup.py "你的遊戲資料夾"

Mac 上把 python 換成 python3

你會看到
【1】本站教學會動到的檔案
  ✅ data/database/attrib.dat         820.9 KB  2026-04-29 07:51  球員資料
  ✅ data/database/pitcher.dat        263.7 KB  2023-05-23 06:31  投手資料
  ✅ data/database/schedule.big       264.9 KB  2026-05-10 18:25  賽程表
  ✅ data/frontend/ingame.big           2.5 MB  2023-02-21 10:49  比賽中的介面
  ✅ controller.cfg                    10.9 KB  2026-04-13 12:42  手把與鍵盤設定

  這 5 個檔合計 3.9 MB(4,058,641 bytes)。
  把它們複製到別的地方,只涵蓋上面列出的那幾課動的檔。
  ⚠️ 本站還有別的課動的是別的檔(models.big / datafile.big / mvp2005.exe /
     portrait.big / uniforms.big / fonts.big / .LOC / 語音檔 …),
     那些不在上面的清單裡。整包備份才是完整的做法,見「備份與還原 SOP」。

日期那一欄是那台電腦上的檔案修改時間,跟你的不會一樣。

如果沒看到
  • 五個全部標 ✗ (不存在),最後印「一個都找不到」→ 你選錯資料夾了。 正確的那一層同時看得到 data 資料夾和 mvp2005.exe
  • 只有 controller.cfg 不存在 → 其他四個檔照樣備份得起來。 這個檔什麼時候會被建立、刪掉之後會不會自己長回來,本站都沒有實測過, 所以有的話還是備一份比較保險。
2
把你剛做的備份資料夾也交給它比對

這一步才是真正在驗「備份對不對」:它會逐個位元組比,不是只看檔名跟大小。

Windows

python mvp_check_backup.py "你的遊戲資料夾" "%USERPROFILE%\Desktop\MVP-backup-20260825"

Mac

python3 mvp_check_backup.py "你的遊戲資料夾" ~/Desktop/MVP-backup-20260825
你會看到(剛備份完,五個都該是「內容一致」)
【3】外部備份資料夾
  ✅ data/database/attrib.dat         820.9 KB  2026-08-25 09:14  內容一致
  ✅ data/database/pitcher.dat        263.7 KB  2026-08-25 09:14  內容一致
  ✅ data/database/schedule.big       264.9 KB  2026-08-25 09:14  內容一致
  ✅ data/frontend/ingame.big           2.5 MB  2026-08-25 09:14  內容一致
  ✅ controller.cfg                    10.9 KB  2026-08-25 09:14  內容一致

  備份資料夾涵蓋全部 5 個檔案。

要看的是「內容一致」這五個字,還有大小要跟遊戲資料夾裡那五個完全相同。 時間欄長什麼樣,要看你用什麼方式複製:上面這份是在 Mac 上用 cp 跑出來的, 所以五行都是複製當下的時間。

如果沒看到
  • 某一行寫 備份資料夾裡沒有 → 那個檔漏複製了,回上一節補做。
  • 某一行寫 內容不同(你改過了) → 你在備份之後又改過那個檔。 如果你想備份的是「還沒改之前」的狀態,這份備份已經來不及了。

⚠ 四種「看起來有備份,其實沒有」——【2】【3】會直接指名

這四句話出現在畫面上的時候,那一行不是綠勾的意思

前三種只要出現一次,【3】那一段就不會印 「備份資料夾涵蓋全部 N 個檔案」—— 那一行要一個檔都不缺、沒有指回正本的、也沒有讀不出來的,才印得出來。

想先確認這支腳本自己沒壞?

它內建一組自我測試,會自己造出上面那幾種情形,看該亮的字有沒有亮。 這一段不碰你的遊戲資料夾:測試檔全部建在系統暫存資料夾裡,跑完自己刪掉。

python3 mvp_check_backup.py --selftest

Windows 上把 python3 換成 python

你會看到(本站實跑)
自我測試(在系統暫存資料夾裡跑,不碰你的遊戲資料夾)
   ✅ 陰性對照:乾淨的資料夾 + 真的另一份備份 = 沒有紅字
   ✅ 餌一:指回正本的「備份」被指名,真的複本仍然說一樣
   ✅ 餌二:指不到東西的備份會被指名,不會被當成沒有備份
   ✅ 餌三:讀不出來的備份照實說,沒被講成內容不同,報告也沒斷
   ✅ 餌四:備份資料夾在遊戲資料夾裡面會被提醒
   ✅ 餌五:遊戲資料夾自己當備份時不會出現「涵蓋全部」
   ✅ 餌六:目標檔是壞掉的連結時不會說成「還沒玩過遊戲」
   ✅ 餌七:外部備份讀不出來時不說成內容不同,也不說涵蓋全部
   ✅ 餌八(陽性對照):真的有兩份內容不同的備份時要講出來
   ✅ 餌八(陰性對照):兩份一模一樣就不可以說成不同
   ✅ 餌八:讀不出來的那一份不會被算成「內容不同的備份」
   ✅ 餌九:檔案在報告中途消失時,四段報告照樣印得完
   ✅ 餌十:外部備份完整時,結論不會說「你也還沒有任何備份」
   ✅ 餌十一(陰性對照):沒有中斷也沒有出錯時,cli() 照樣回 0
   ✅ 餌十一:掃描途中按 Ctrl-C 會講一句中文並以 130 收工,不噴 traceback
   ✅ 餌十二:掃描途中讀取出錯會講一句中文並以 2 收工,不噴 traceback
   ✅ 自我測試全部通過

十六道檢查全綠時回傳值是 0,任何一道沒過是 1; 在 python3 -O 底下它會拒跑,結束碼 2-O 會把 assert 整段拿掉, 「測試沒跑」跟「測試沒過」是兩回事,不混在同一個數字裡)。 它驗的是腳本自己,不會告訴你你的備份好不好 —— 那是上面兩步的事。

不想裝 Python 也可以確認

用檔案總管 / Finder 看檔案大小的數字對不對, 就能擋掉「漏複製」跟「複製到一半中斷」這兩種最常見的失敗: 備份資料夾裡那五個檔的大小,要跟遊戲資料夾裡那五個一模一樣, 不是「差不多」而是連個位數都要相同。 Windows 在檔案上按右鍵 → 內容,看「大小」那一行的括號內數字; Mac 按 ⌘ Cmd + I 看「大小」。

這招看不出「內容不同但大小剛好一樣」的情況。本站量到一個現成的例子: attrib.dat 改一位球員的跑壘速度之後, 840,643 還是 840,643,只有 1 個位元組不同。那種情況才需要腳本。

五 · 怎麼還原

有兩條路。先試第一條,不行再走第二條。

路線 A:用該課工具的 --restore(快,但只退一步)

你跑過哪一課還原指令
把賽程換到任何年份 python mvp_shift_schedule_year.py "…\data\database\schedule.big" --restore
關掉跑壘者速度數值 python mvp_hide_runner_speed.py "…\data\frontend\ingame.big" --restore
介面實例集 · 介面萬用工具 python mvp_fel_toolkit.py "你的遊戲資料夾" --restore
修改球員跑壘速度 python mvp_edit_speed.py "你的遊戲資料夾" --restore
幫球員換一張臉 python mvp_swap_face.py "你的遊戲資料夾" --restore
換打擊姿勢與投球姿勢 python mvp_edit_stance.py "你的遊戲資料夾" --restore
同一課的手動改法 沒有腳本。把你自己複製的 ingame.big.bak 改名回 ingame.big 蓋回去。 這個檔名剛好就是 mvp_hide_runner_speed.py --restore 會去找的那一個, 所以你手上如果有 Python,那道指令也吃得動它
暖身課 controller.cfg 沒有腳本。把桌面那份複製回去覆蓋, 或進遊戲 → 設定 → 手把設定 → 選「還原預設」
把打擊視角調成你喜歡的角度 python mvp_fel_toolkit.py "你的遊戲資料夾" --big data\datafile\datafile.big --restore-datafile
中文版配大球場就跳出
(三支工具各還原各的)
python mvp_modernize.py "…\mvp2005.exe" --restore
python mvp_font_swap.py "你的遊戲資料夾" --restore
python mvp_fix_loc.py "你的遊戲資料夾" --restore
2026 年的電腦怎麼跑得起 2005 年的遊戲 python mvp_modernize.py "…\mvp2005.exe" --restore
球場超過 10MB 就當機 python mvp_shrink_big.py "…\data\stadium\那個檔.big" --restore
把一套球衣裝進遊戲 python mvp_install_uniform.py "你的遊戲資料夾" --restore
做一張新的球員臉皮 python mvp_new_face.py "你的遊戲資料夾" --restore
換球員大頭照 python mvp_swap_portrait.py "你的遊戲資料夾" --restore
換掉開機畫面 python mvp_swap_screen.py "你的遊戲資料夾" --restore
把應援曲指給別的球員 python mvp_swap_chant.py "你的遊戲資料夾" --restore
換掉遊戲的聲音 python mvp_audio.py "…\那個聲音檔" --restore
封裝檔可以拆成散裝嗎 python mvp_unpack_big.py "…\那個檔.big" --restore
一整包模組自動歸位 python mvp_autoinstall.py "你的遊戲資料夾" --restore
把一位球員改成你想要的球員 python mvp_player.py "你的遊戲資料夾" --restore
把真實成績換算成遊戲裡的能力值 python mvp_ratings.py --restore-write "你的遊戲資料夾" (這一支的還原參數叫 --restore-write,而且資料夾放在參數後面)
改記分板的顏色 python mvp_hud_color.py "你的遊戲資料夾" --restore
改主選單的文字 python mvp_menu_text.py "你的遊戲資料夾" --restore
換球隊隊徽 python mvp_team_logo.py "你的遊戲資料夾" --restore
關掉開場動畫 python mvp_skip_intro.py "你的遊戲資料夾" --restore
執行檔上的六個旋鈕 python mvp_exe_pe.py "…\mvp2005.exe" --restore
把官方中文版的中文搬進英文版
(三支工具各還原各的)
python mvp_fix_loc.py "你的遊戲資料夾" --restore
python mvp_font_swap.py "你的遊戲資料夾" --restore
python mvp_modernize.py "…\mvp2005.exe" --restore
把你改好的東西打包成一包 (用它安裝別人的模組之後) python mvp_pack.py "你的遊戲資料夾" --restore
一次更新整個年度 沒有自己的工具。它帶你跑的是上面那些課, 所以要還原就照你跑過的那幾課,各自跑各自的 --restore; 一次退乾淨還是路線 B
球衣模組:安裝與製作入門 沒有本站腳本(那一課用社群的 Windows 工具)。 只能把你自己複製的 models.big / uniforms.big / logos.big 蓋回去,也就是路線 B

有的工具吃的是「檔案路徑」(換賽程年份、關掉跑壘者速度數值、 mvp_modernize.pymvp_exe_pe.pymvp_shrink_big.pymvp_unpack_big.pymvp_audio.py),有的吃的是「遊戲資料夾」,這不是筆誤,照表上抄就好。 Mac 上把 python 換成 python3--restore 是拿第一次建立的那份備份蓋回去,所以會把該課做過的所有修改一次收回去, 不是只收最後一次。

⚠ 跑過兩支動同一個檔的工具?先看清楚哪一份比較舊

本站二十六支會寫遊戲檔的腳本一共留下二十五種備份副檔名 (2026-09-05 在 site/tutorials/ 底下數的:30 支腳本裡有 4 支不寫遊戲檔, 剩下 26 支各自留備份,多一課會寫檔的教學通常就多一種。 這 26 支裡有一支是打包工具自己。 打包那一課數的是同一批二十六支、同樣是 25 種, 只是它接著把「扣掉打包工具自己留的 .packbak 之後的 24 種」 單獨提出來講,因為那才是它的 --scan 要掃的、別的課留下的痕跡 (mvp_pack.py 自己那份 BAK_SUFFIXES 就是 24 項)。 兩邊都對,只是清單口徑不同這個數字每加一課就會變,要知道現在是幾種,跑 .tools/site-check/selftest_backup_suffixes.py 最準): .bak / .speedbak / .facebak / .feltoolbak / .datafilebak / .facetexbak / .unibak / .portraitbak / .screenbak / .chantbak / .audiobak / .locbak / .fontbak / .modernizebak / .shrinkbak / .unpackbak / .autobak / .playerbak / .ratingsbak / .hudcolorbak / .logobak / .menutextbak / .exepebak / .introbak / .packbak。 其中三支都用 .bak(換賽程年份、關掉跑壘者速度數值、換打擊姿勢與投球姿勢), 但它們動的是不同的檔,所以還是不會互相覆蓋 —— 也因此看備份要連檔名一起看,不能只看副檔名。 好處是備份不會被蓋掉, 代價是同一個檔可能同時存在兩三份內容不同的備份

下一節的檢查腳本這二十五種全部認得(原始碼裡的 BAK_SUFFIXES 就是同一份清單:2026-09-05 在 site/tutorials/backup/mvp_check_backup.py 上數到 25 個項目; 守門的是 .tools/site-check/selftest_backup_suffixes.py, 它把兩邊對一次,跑起來是綠的。不過它守得住的是這二十五種裡的二十三種: 2026-09-05 讀它的原始碼,它掃 site/tutorials/ 時會跳過 pack-a-modbackupcrash-diagnosis 這三課 (那幾份清單自己住的地方), 而且它認副檔名的樣式要求點後面至少一個字母才接 bak, 通用的 .bak 因此從頭到尾配不到。所以 .bak.packbak 哪天從清單裡掉了它還是綠的,那兩種要自己看)。 (2026-09-05 訂正:這裡原本寫「跟每一支腳本字面寫出的備份副檔名一個不多、一個不少」。 那種全稱保證放兩天就會過期 —— 2026-09-04「關掉開場動畫」那一課上線, 它留的 .introbak 當天就不在清單裡,守門腳本因此報紅; 2026-09-05 補進清單才恢復綠燈。所以這頁改成叫你自己跑守門腳本。) 但「認得」不等於「掃得到」:那支腳本只看核心五個檔旁邊有沒有備份, 所以留在 mvp2005.exe 旁邊的 .exepebak(改執行檔那一課留的) 與 .modernizebak(2026 年怎麼跑得起來那一課留的), 還有留在 movies 資料夾影片旁邊的 .introbak(關掉開場動畫那一課留的), 都不會出現在它的【2】那一段, 那幾份備份的先後順序要自己看檔案的修改時間排。

實測:拿一份 attrib.dat,先跑「修改球員跑壘速度」,再跑「幫球員換一張臉」, 然後把三個檔各自跟動手前那一份用 cmp -l 逐位元組比對:

attrib.dat.speedbak   跟動手前差 0 個位元組   ← 這份最舊
attrib.dat.facebak    跟動手前差 1 個位元組   ← 已經含著改速度那一次
attrib.dat(現在的)   跟動手前差 2 個位元組   ← 兩次改動都在

所以這時候打 mvp_swap_face.py --restore, 你只會退回「速度已經改過」的狀態。 最舊的那一份備份,才是你第一次動手之前的樣子, 上一節的檢查腳本會直接把它指出來。

檢查腳本印的字是「它才是原廠檔」。這裡的「原廠」是相對於你動手之前, 不是 EA 出廠。量測用的那台,attrib.dat 本身就已經是裝過中文模組的檔案。 要退到真正的 EA 出廠狀態,只有重裝,或是用你在裝模組之前做的那份備份。

封裝檔更好認:三份大小全不一樣

同一件事發生在 ingame.big 上會直接寫在檔案大小裡。 實測先跑「關掉跑壘者速度數值」再跑「介面萬用工具」:

ingame.big.bak          2,665,562 bytes   ← 最舊
ingame.big.feltoolbak   2,750,503 bytes   ← 已含第一次修改
ingame.big(現在的)     2,828,256 bytes   ← 兩次都在

本站寫 .big 的腳本都是往檔尾接(球場瘦身那一課除外),檔案只會越改越大, 所以在這些工具底下,比較小的那一份備份就是比較早的那一份球場瘦身那一課反過來:它把目錄沒指到的位元組清掉,檔案會變小, 所以 .shrinkbak 一定比現在的檔,這條規則對它不成立。 純文字的 attrib.dat 沒有這個線索:它是逗號分隔、欄位不補零,所以大小通常不變,只有改動前後的位數不同時才會差一兩個位元組(實測把 93 改成 5,檔案從 840,643 掉到 840,642)。大小不是可靠的先後線索。

路線 B:從你自己的備份複製回去(退到你備份的那一刻)

只要第三節那份備份還在,這條路就不受你中間跑過幾支工具、改過幾次、 工具的備份檔被誰蓋掉影響。它把你按下複製那一刻的狀態放回去, 不會比那個時間點更早

Windows(以還原 ingame.big 為例,其他檔同理換路徑)

copy /Y "%USERPROFILE%\Desktop\MVP-backup-20260825\data\frontend\ingame.big" "你的遊戲資料夾\data\frontend\"

Mac

cp ~/Desktop/MVP-backup-20260825/data/frontend/ingame.big "你的遊戲資料夾"/data/frontend/
你會看到

Windows 回一句表示複製了 1 個檔案的訊息;Mac 一樣什麼都不印。

如果印「拒絕存取」/ Permission denied

六 · 怎麼確認還原成功

這裡有一件事一定要先講清楚:工具會不會自己驗,每一支不一樣。 這不是誰寫壞了,是各課寫成的時間不同。 本站實際跑過下面這六支--restore(也就是核心五檔那一組),印出來的是這樣:

工具還原後印什麼有沒有自己驗位元組
mvp_edit_speed.py 已從備份還原 → attrib.dat
已還原,前 N 行裡有 M 行不同。
沒有 —— 它比的是還原前那個檔跟還原後差幾行
mvp_swap_face.py 已從備份還原 → attrib.dat
已還原,跟還原前有 N 行不同。
沒有 —— 同上
mvp_fel_toolkit.py 位元組完全一致:✅ 是
mvp_shift_schedule_year.py ✓ 已從備份還原:schedule.big.bak → schedule.big沒有
mvp_hide_runner_speed.py ✓ 已從備份還原:ingame.big.bak → ingame.big沒有
mvp_edit_stance.py ✓ 已還原 attrib.dat(打擊姿勢)沒有

另外二十支會寫檔的工具,本站沒有逐支實跑過 --restore 再把畫面抄下來, 所以這張表不涵蓋它們;不管哪一支,下面那三項驗收條件都照樣適用。 印「已還原」的那幾支只代表複製這個動作沒出錯,沒有回頭確認結果。 mvp_fel_toolkit.py 的「位元組完全一致」也要看清楚它比的是什麼:讀它的原始碼, 順序是先把自己那一份備份複製到原檔上,再把那份備份跟複製出來的結果對讀。 所以它抓得到的是「複製到一半失敗」, 它不會告訴你有沒有回到你動手之前的狀態。那件事要靠檢查腳本。

(2026-09-05 訂正:這張表原本說 mvp_edit_speed.pymvp_swap_face.py 也會印「位元組完全一致:✅ 是」。 那兩支後來把那一行拿掉了 —— 它們的原始碼裡寫著理由:那句話比的是 「剛複製過去的檔」跟「複製的來源」,複製成功就一定相等,等於只是在報告自己剛複製成功。 現在它們改成跟還原前的檔比,告訴你這次還原到底改動了幾行。 所以本站實跑過的六支裡,只剩 mvp_fel_toolkit.py 一支還印那一行。)

以下三項都成立才算還原完成。少一項就當作沒還原。

還原乾淨的樣子(實跑:關掉跑壘者速度數值 → --restore)
【2】遊戲資料夾裡的備份檔
  ✅ ingame.big.bak                     2.5 MB  2023-02-21 10:49  跟現在的檔案一樣
     ↑ 「關掉跑壘者速度數值」這一課建立的
沒還原乾淨的樣子(這是真的跑出來的)

先跑「關掉跑壘者速度數值」、再跑「介面萬用工具」,然後只用 mvp_fel_toolkit.py --restore 還原:

【2】遊戲資料夾裡的備份檔
  ✅ ingame.big.bak                     2.5 MB  2023-02-21 10:49  跟現在的檔案不同
     ↑ 「關掉跑壘者速度數值」這一課建立的
  ✅ ingame.big.feltoolbak              2.6 MB  2026-08-25 09:17  跟現在的檔案一樣
     ↑ 「介面萬用工具 fel_toolkit」這一課建立的
     ⚠ ingame.big 有 2 份內容不同的備份 —— 它們是不同時間點的快照。
       最舊的是 ingame.big.bak,它才是原廠檔;
       其他幾份裡面已經含著前一次的修改。

工具那邊印的是「位元組完全一致:✅ 是」, 完全沒有報錯,因為它確實把 它自己那份備份還原乾淨了。 但檢查腳本一眼就看出來:離動手之前還差一步。 要再退一層,就再跑一次 mvp_hide_runner_speed.py …\ingame.big --restore, 或直接走路線 B。

上面 ingame.big.bak 那一行的日期是 2023,不是今天: 工具建立備份用的是「連同時間一起複製」,所以備份檔帶著的是它保存的那份內容的時間。 這也是檢查腳本能排出先後的依據。

出錯了?對照這張表

畫面上的訊息原因你要做的
can't open file '…\mvp_*.py' 檔案還在「下載」資料夾,不在桌面 開檔案總管左邊點「下載」,把那個 .py 拖到桌面,再跑一次。 這是本站最常見的第一個卡點。
✗ 找不到這個資料夾路徑打錯,或路徑有空格卻沒加引號 用拖曳取得路徑(第 0 課操作二
一個都找不到 —— 你選的可能不是遊戲安裝資料夾 選到上一層或下一層了 同時看得到 datamvp2005.exe 的那一層
找不到備份 xxx.bak 那支工具沒有改過這個檔,或備份被搬走/刪掉了 改走路線 B,從你自己的備份資料夾複製回去
⚠ attrib.dat 有 2 份內容不同的備份 你先後跑過兩支動同一個檔的工具 看腳本印的「最舊的是 ___」, 那一份是你第一次動手之前的樣子(裝過模組的話,它是模組的狀態)
位元組完全一致:❌ 否 把備份複製回原檔的過程本身出錯了 改走路線 B,然後照下面的範本回報
拒絕存取 / Permission denied 遊戲還開著,或遊戲裝在 C:\Program Files 底下 先關遊戲;還不行就用系統管理員身分開命令提示字元

📋 卡住的話,照這個格式回報

作業系統:Windows 11 / Windows 10 / macOS ___
卡在哪一節:一 / 二 / 三 / 四 / 五 / 六
我打的指令:(整行複製貼上)
畫面印出什麼:(整段複製貼上)
檢查腳本【1】那一段印出幾個 ✅:___ 個
檢查腳本【2】那一段有沒有出現「有 N 份內容不同的備份」:有 / 沒有

這支腳本在做什麼

它把「你到底備份了沒有」拆成四個問題,照順序各回答一次, 而且四個問題全都是唯讀的: 【1】本站教學會動到的那幾個檔在不在、多大、什麼時候改的; 【2】這些檔旁邊有沒有工具自動留下的備份,那些備份跟現在的檔一不一樣; 【3】你自己那個備份資料夾裡有沒有對應的檔,內容一不一樣; 【4】把前面一路記下來的「值得注意」收成一段結論。

判斷「一不一樣」靠的是逐位元組比對,不是比大小、也不是比時間。 大小不同才會提早結束;大小一樣的話,兩個檔會整個讀完才敢說一樣。 這件事很重要,因為大小一樣但內容不同的檔案, 正是備份出問題時最常見的樣子。

哪一段做什麼為什麼要有它
TARGETS(表) 列出本站教學會動到的五個檔:相對路徑、這個檔是什麼、 會動到它的是哪幾課。 這張表決定【1】要去找哪些檔。 它是逐篇讀教學內文與腳本的寫入點整理出來的,不是照副檔名猜的; 也因此它只涵蓋一部分課,其餘的課動的檔不在表上, 畫面上會另外提醒你這件事。
BAK_SUFFIXES(表) 本站工具留備份時用的那些備份副檔名,2026-09-05 數是 25 種 (這個數字每加一課就會變,腳本自己的註解也刻意不寫死它)。 漏一個的後果是靜默的: 那支工具留的備份會完全看不見,而畫面上不會有任何提示, 你只會以為自己沒備份。所以本站有一支專門的檢查器在守這份清單, 每新增一課會寫檔的教學就要回來加一筆。
BAK_OWNERBAK_OWNER_BY_SUFFIX(表) 把「原檔名 + 備份副檔名」對回是哪一課建立的。 先查兩者的組合,查不到再退回只看副檔名。 順序不能反:同一個 .bak 出現在不同檔案上,是不同的工具留的,只看副檔名會認錯人。 兩張表都查不到就誠實說「不是本站工具建立的」,不冒認。
main() 的【1】 逐個檔看在不在,印出大小與最後修改時間, 並把合計的位元組數算出來。 大小與時間是給你自己認的: 「這份備份對不對得上我改之前那個時間點」只有你知道。 controller.cfg 找不到不算問題, 那個檔要玩過一次遊戲才會出現,所以它被單獨放行。
main() 的【2】 在原檔旁邊找「原檔名 + 備份副檔名」的備份, 每一份都跟現在的檔比一次,並寫出是哪一課留的。 同一個檔可能被好幾支工具動過。 有兩份以上內容不同時,它會挑出最舊的那一份說 「它才是原廠檔」,因為每支工具只在第一次寫入前備份, 晚一點那幾份裡已經含著前一次的修改。
main() 的【3】 給了外部備份資料夾的話,逐檔找對應的備份再逐位元組比。 保留原資料夾結構、或全部平放在同一層,兩種擺法都認。 「複製過去了」跟「複製對了」是兩件事。 少一個檔通常是還原那天才會發現,而那天已經來不及了, 所以現在就把它記成一條。
main() 的【4】 把一路收集的「值得注意」列出來。 一條都沒有的時候再分兩種講:你給的外部備份資料夾是完整的, 印的是「遊戲資料夾裡沒有工具留的備份 —— 你還沒動手改,那是正常的。」 加上那份備份涵蓋了【1】列的每一個檔; 連外部備份都沒給,才印「目前沒有需要處理的事 —— 但你也還沒有任何備份。 什麼都還沒改過的機器本來就不該有備份, 那不是問題,是還沒開始。但也不可以因此說成「每一個都有備份」, 沒有問題不等於已經備份好了。這兩句話混在一起講, 等於把「你還沒開始」講成「你已經安全了」。 分出第三種講法也是同一個道理:【3】上面三行剛印完「涵蓋全部」, 結論再說「你也還沒有任何備份」,就是同一個畫面自己打自己。
same_bytes() 逐位元組比兩個檔,一次讀 1 MB 對一段。 大小不同就直接回「不一樣」,連開檔都不必。 「一樣」是整個檔讀完才敢說的話。 切塊讀是因為這張表上的 ingame.big 在本站的測試機上是 2.5 MB,整個吃進記憶體沒有必要。
human()when() 把位元組數換成人看得懂的單位; 把最後修改時間印出來,只到分鐘 時間只到分鐘是刻意的:秒數對判斷 「哪一份比較舊」沒有幫助,卻會把這台機器的活動時間 精確地印在你要貼給別人看的畫面上。

唯讀不是形容詞:檢查那條路上唯一的開檔動作 是比對用的那兩個 'rb',對你的遊戲資料夾與備份資料夾沒有 open(..., 'w')、沒有 shutil.copy、也沒有 os.remove。 (2026-09-05 訂正:這頁原本寫「整支腳本唯一的開檔動作」。腳本後來加了一段 --selftest 自我測試,那一段會建檔、寫檔、刪檔 —— 但它建的檔全部在 系統暫存資料夾裡,跑完自己刪掉,不碰你的遊戲。所以「唯讀」這句話的範圍是 「你的遊戲資料夾與備份資料夾」,不是「整支檔案」。) 做不到的事同樣寫在腳本開頭:它只認得放在原檔旁邊、用原檔名加備份副檔名 命名的備份(你自己改過名字或搬走的它看不到,那不代表你沒備份), 也不會判斷備份內容合不合理,只回答「跟現在的檔一不一樣」。

附錄:檢查腳本原始碼

零相依,只用 Python 內建功能。整份 933 行裡(程式碼到第 909 行,之後 23 行是 MIT 授權條款), 檢查那條路上只有一行在開檔(第 289 行同時開兩個檔做比對), 而且兩個都是唯讀的 'rb'。 會建檔、寫檔、刪檔的只有第 345 行開始的 self_test() (要打 --selftest 才會跑),它用的是 tempfile.mkdtemp 開在系統暫存資料夾裡的自己的測試檔,跑完自己刪掉。 你可以自己搜尋 open( 確認再跑。

一個小提醒:程式裡 TARGETS 上面那段註解寫「會寫入的檔案」, 但 controller.cfg 是例外,暖身課只請你打開它來看,並不寫入。 腳本印出來的標題是「本站教學會動到的檔案」,那個講法才準。

展開 / 收合完整原始碼(933 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_check_backup.py
檢查 EA MVP Baseball 2005 的備份狀況。

    python3 mvp_check_backup.py "遊戲安裝資料夾"
    python3 mvp_check_backup.py "遊戲安裝資料夾" "你的外部備份資料夾"
    python3 mvp_check_backup.py --selftest

例如:
    python3 mvp_check_backup.py "C:\\Program Files (x86)\\EA SPORTS\\MVP Baseball 2005"

這支腳本**全程唯讀**,不會建立、修改或刪除任何檔案。它只回答四個問題:

  1. 本站教學會動到的檔案,在不在?多大?什麼時候改的?
  2. 這些檔案旁邊有沒有工具自動建立的備份?
  3. 那些備份跟現在的檔案一不一樣(還原後應該一樣)?
  4. 給了外部備份資料夾的話,那份備份跟現在的檔案差在哪?

吃什麼(輸入)
  · 第一個參數:遊戲安裝資料夾,也就是同時看得到 data 這個子資料夾和 .exe 的那一層。
    拖一個檔進來(mvp2005.exe 或 controller.cfg)也可以,腳本會自己往上一層走。
  · 第二個參數(可以不給):你自己放備份的那個資料夾。
    保留原本的資料夾結構、或把檔案全部平放在同一層,兩種擺法都認得。

吐什麼(輸出)
  · 只有畫面上那四段報告。你的遊戲資料夾與備份資料夾裡沒有任何檔案被建立、
    修改或刪除,對它們唯一的開檔動作是比對用的兩個唯讀 'rb'。
  · 唯一的例外是 --selftest:它在系統暫存資料夾裡建自己的測試檔來驗證下面
    那幾道檢查真的會亮,跑完自己刪掉,全程不碰你的遊戲資料夾。
  · 回傳值:0 = 檢查完了(或自我測試全綠);
    1 = 參數不對、那個資料夾裡一個目標檔都找不到,或自我測試沒過;
    2 = 掃描途中讀取出錯(外接碟斷線、檔案被別的程式鎖住),
        或是在 python3 -O 底下跑 --selftest —— 它會拒跑。
        「測試沒跑」跟「測試沒過」是兩回事,所以不跟 1 混在一起;
        「碟不見了」跟「路徑打錯了」也是兩回事,所以也不跟 1 混在一起;
    130 = 你按了 Ctrl-C。

安全網
  · 唯讀是唯一的模式:沒有 --apply,也沒有還原功能。要還原請照
    「備份與還原 SOP」自己把檔案複製回去,這支只負責告訴你複製對了沒有。
  · 「一不一樣」是**逐位元組**比出來的,不是比大小、也不是比時間。
    大小不同才提早結束,大小一樣就整個讀完才敢說一樣。
  · 一份「備份」如果其實就是正本本身(符號連結、硬連結,或把遊戲資料夾
    自己當成第二個參數),逐位元組比對必然說「一樣」。這種情形會被指名
    講出來,不會給你一個綠勾 —— 那個綠勾會害人放心去改檔。
  · 讀不出來的檔(權限不足、壞掉的連結)會照實說「讀不出來」,
    不會混進「不一樣」裡,也不會讓整份報告中途斷掉。
  · 中途按 Ctrl-C 就只是少印幾行:這支從頭到尾沒有開任何檔案來寫,
    也沒有搬移或刪除,所以停在哪裡都不會留下半截的東西。它會講一句話
    告訴你這件事,而不是丟一串 Python 的錯誤訊息給你自己猜。
  · 同一個檔有好幾份內容不同的備份時,會指出**最舊的那一份才是原廠檔**,
    因為每支工具只在第一次寫入前備份,晚一點的那幾份裡已經含著前一次的修改。

做不到的事
  · 只看本站教學會動到的那幾個檔,不是整包遊戲的完整清單。
    別的課動的檔(models.big / mvp2005.exe / 語音檔 …)不在表上,
    完整的做法還是整包備份。
  · 只認得「原檔名 + 備份副檔名」而且放在原檔旁邊的備份。
    你自己改過名字、或搬去別的資料夾的那些,它看不到(那不代表你沒備份)。
  · 不會判斷備份內容合不合理(例如中途斷掉的半截檔),
    它只回答「跟現在的檔一不一樣」。
  · controller.cfg 沒有任何本站工具會替它備份(暖身課是請你自己用記事本改),
    所以它旁邊永遠不會出現備份檔。畫面上會直接請你自己複製一份,
    不會把它寫成「哪一支工具漏了」。

無外部相依,Python 3.7 以上即可。
授權:MIT(見檔尾完整條款)。本站教學文字另採 CC BY 4.0。
"""
#
# ─────────────────────────────────────────────────────────
#  法律與免責(每一支本站腳本都帶著這一段)
#
#  · 本工具與 Electronic Arts 無任何官方關聯,也未經其授權或背書。
#    MVP Baseball 2005 為 Electronic Arts 之作品與商標。
#  · 本工具為原創程式碼,**不含任何 EA 的程式碼或資產**。
#  · 本工具不提供、不教學、也不包含任何規避技術保護措施的功能。
#  · 使用者應僅對自己合法取得的遊戲副本使用本工具,並自行承擔風險。
#    使用前請自行確認你與遊戲發行商之間的使用者授權合約(EULA)。
#  · 本工具按「現狀」提供,不附任何明示或默示的擔保。
#  · 授權:MIT(見檔尾)。教學文字另採 CC BY 4.0。
#  · 回報與下架:https://toniliumvp.github.io/MVPBaseball/report.html
#    三條管道,其中「直接向 GitHub 提出」不需經過維護者;
#    留言區那條不需要任何帳號。管道有變動只會改那一頁。
# ─────────────────────────────────────────────────────────

import io
import os
import sys
import time
from pathlib import Path

# Windows 主控台預設編碼存不下 ✓ ✗ 這類符號,先把輸出轉成 UTF-8。
try:
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except (AttributeError, ValueError):
    pass

# ── 本站教學實際會寫入的檔案 ────────────────────────
# 逐篇讀教學內文與腳本的寫入點整理出來的。唯讀的那幾課(例如五秒看穿一個檔案、
# 遊戲當掉了怎麼辦)不寫任何檔案,所以不在這張表上。
# ⚠️ 這裡不寫「幾篇教學」的數字:本站每加一課它就過期,而且沒有人會回頭改它。
# 一筆 = (相對於遊戲資料夾的路徑, 這個檔是什麼, 會動到它的課)。
# 第三欄目前沒有印在畫面上,留著是為了改這張表的時候,看得出動它的是哪幾課。
# ⚠️ 第三欄只列「指名改這個檔」的課。「一整包模組自動歸位」與「把你改好的
#    東西打包成一包」是通用安裝器,它們可能寫進下面任何一個檔,不逐列重複寫。
TARGETS = [
    ('data/database/attrib.dat',   '球員資料',
     ['修改球員跑壘速度', '幫球員換一張臉', '換打擊姿勢與投球姿勢',
      '把一位球員改成你想要的球員', '把真實成績換算成遊戲裡的能力值']),
    ('data/database/pitcher.dat',  '投手資料',
     ['換打擊姿勢與投球姿勢', '把真實成績換算成遊戲裡的能力值']),
    ('data/database/schedule.big', '賽程表',
     ['把賽程換到任何年份']),
    ('data/frontend/ingame.big',   '比賽中的介面',
     ['關掉跑壘者速度數值', '介面實例集', '介面萬用工具', '改記分板的顏色']),
    ('controller.cfg',             '手把與鍵盤設定',
     ['暖身課:打開一個遊戲的檔案']),
]

# 玩過一次遊戲才會被建立的檔。沒有它不代表有問題。
# ⚠️ 這裡比對的是 TARGETS 第一欄那個字串本身,不是檔名。
#    哪天把上面那筆改成帶資料夾的寫法,這裡就對不上了,而且不會報錯,
#    只會讓一台剛裝好的機器被說「找不到 controller.cfg」。
BORN_ON_FIRST_RUN = {'controller.cfg'}

# 本站沒有任何腳本會自動備份它的檔。暖身課是請你用記事本手動改 controller.cfg,
# 沒有工具在旁邊留備份,所以【2】那一段對它**永遠**是「沒有備份」。
# ⚠️ 不要因為「消不掉」就把這一項藏起來 —— 沒備份就是沒備份,藏起來是幫倒忙。
#    要改的是措辭:講清楚該自己複製一份,而不是讓人以為哪一支工具漏了。
NO_TOOL_BACKUP = {'controller.cfg'}

# ⚠️ 這份清單漏一個,那一支工具留的備份就完全看不見,而且不會有任何提示。
#    守門的是 .tools/site-check/selftest_backup_suffixes.py,不是 verify_site.py
#    (那一支沒有這項檢查)。它把教學腳本字面寫出的備份副檔名跟這份清單對一次,
#    但配不到通用的 .bak,也跳過 pack-a-mod 與 backup 兩課,那幾種少了不會紅。
# ⚠️ 這份清單的長度會一直變(多一課會寫檔的教學通常就多一種),所以這裡
#    **不寫死「幾支、幾種」** —— 那種數字放兩天就過期,而且沒有人會回頭改。
#    要知道現在缺不缺,跑上面那支檢查器,它會把兩邊對一次。
#    歷史上補過兩次:2026-09-03 補 .exepebak(執行檔那一課),
#    2026-09-05 補 .introbak(關掉開場動畫那一課,2026-09-04 才進站)。
# ⚠️ 補進清單不等於畫面上看得到:.exepebak 留在 mvp2005.exe 旁邊、
#    .modernizebak 同理、.introbak 留在 movies 資料夾的 .vp6 影片旁邊,
#    那三個檔都不在上面的 TARGETS 裡,所以【2】那一段仍然不會列它們。
#    TARGETS 只認核心五個檔,這是刻意的。
#    清單的順序只決定畫面上列出來的先後,不影響任何判斷。
BAK_SUFFIXES = [
    '.bak', '.audiobak', '.autobak', '.chantbak', '.datafilebak',
    '.exepebak', '.facebak',
    '.facetexbak', '.feltoolbak', '.fontbak', '.hudcolorbak', '.introbak',
    '.locbak', '.modernizebak',
    '.logobak', '.menutextbak', '.packbak', '.playerbak', '.portraitbak', '.ratingsbak',
    '.screenbak',
    '.shrinkbak', '.speedbak', '.unibak', '.unpackbak',
]

# 哪一支工具會留下哪一個備份檔。同一個 .bak 副檔名在不同檔案上是
# 不同工具留的,所以要連檔名一起看,不能只看副檔名。
# 鍵是 (原檔名, 備份副檔名) 的組合。查得到就能在畫面上寫出「這份是哪一課建立的」,
# 查不到就誠實說不知道,不要冒認成本站的工具。
BAK_OWNER = {
    ('attrib.dat',   '.bak'):        '換打擊姿勢與投球姿勢',
    ('attrib.dat',   '.speedbak'):   '修改球員跑壘速度',
    ('attrib.dat',   '.facebak'):    '幫球員換一張臉',
    ('pitcher.dat',  '.bak'):        '換打擊姿勢與投球姿勢',
    ('schedule.big', '.bak'):        '把賽程換到任何年份',
    ('ingame.big',   '.bak'):        '關掉跑壘者速度數值',
    ('ingame.big',   '.feltoolbak'): '介面萬用工具 fel_toolkit',
}

# 上面那張表處理的是「同一個副檔名出現在不同檔案上」的情形(只有 .bak 這樣)。
# 其餘每一個副檔名都只屬於一支工具,看副檔名就知道是誰留的。
BAK_OWNER_BY_SUFFIX = {
    '.audiobak':     '換掉遊戲的聲音',
    '.autobak':      '一整包模組自動歸位',
    '.chantbak':     '把應援曲指給別的球員',
    '.datafilebak':  '介面萬用工具 fel_toolkit',
    '.exepebak':     '執行檔上的六個旋鈕',
    '.facebak':      '幫球員換一張臉',
    '.facetexbak':   '做一張新的球員臉皮',
    '.feltoolbak':   '介面萬用工具 fel_toolkit',
    '.fontbak':      '中文版配大球場就跳出',
    '.locbak':       '中文版配大球場就跳出',
    '.modernizebak': '2026 年的電腦怎麼跑得起 2005 年的遊戲',
    '.hudcolorbak':  '改記分板的顏色',
    '.introbak':     '關掉開場動畫',
    '.logobak':      '換球隊隊徽',
    '.menutextbak':  '改主選單的文字',
    '.packbak':      '把你改好的東西打包成一包(安裝前留的)',
    '.playerbak':    '把一位球員改成你想要的球員',
    '.portraitbak':  '換球員大頭照',
    '.ratingsbak':   '把真實成績換算成遊戲裡的能力值',
    '.screenbak':    '換掉開機畫面',
    '.shrinkbak':    '球場超過 10MB 就當機',
    '.speedbak':     '修改球員跑壘速度',
    '.unibak':       '把一套球衣裝進遊戲',
    '.unpackbak':    '封裝檔可以拆成散裝嗎',
}


def human(n):
    """把位元組數寫成人看得懂的樣子。

    1024 以下直接講 bytes:對「這個檔多大」這種問題,342 bytes 比 0.3 KB 好懂。
    到 GB 就停住不再往上換,遊戲檔不會大到需要更上面的單位。
    None 是「大小讀不出來」,不是 0 —— 印成 0 bytes 會讓人以為那個檔是空的。
    """
    if n is None:
        return '(讀不到)'
    if n < 1024:
        return '%d bytes' % n
    for unit in ('KB', 'MB', 'GB'):
        n /= 1024.0
        if n < 1024 or unit == 'GB':
            return '%.1f %s' % (n, unit)
    return '%.1f GB' % n


def size_of(path):
    """檔案大小;讀不到就回 None(權限不足、剛好被刪掉、壞掉的連結)。

    這支是在別人的機器上跑的,拿不到大小是常態不是例外。原本直接 .stat()
    會讓整份報告在中途噴 traceback,後面幾段就都看不到了。
    """
    try:
        return path.stat().st_size
    except OSError:
        return None


def when(path):
    """這個檔上次被改的時間,只到分鐘;讀不到就照實說。"""
    # 只顯示到分鐘。秒數對判斷「哪一份比較舊」沒有幫助,
    # 卻會把這台機器的活動時間精確地印出來。
    try:
        return time.strftime('%Y-%m-%d %H:%M',
                             time.localtime(path.stat().st_mtime))
    except OSError:
        return '(讀不到)'


def mtime_of(path):
    """排序用的修改時間;讀不到就當成無限新。

    「最舊的那一份才是原廠檔」是要印給人看的結論,挑錯了會害人還原到
    已經改過的版本。讀不到時間的那一份寧可排到最後,也不要被誤選成最舊。
    """
    try:
        return path.stat().st_mtime
    except OSError:
        return float('inf')


def same_file(a, b):
    """兩個路徑是不是「同一個檔」(符號連結、硬連結,或根本同一條路徑)。

    ⚠️ 這是這支腳本能犯的最嚴重的錯:一份「備份」如果其實就是正本本身,
       逐位元組比對必然回 True,畫面上就出現一個綠勾,讀者於是放心去改檔 ——
       而那一刻備份跟著一起被改掉,等到要還原才發現什麼都沒有。
       實測兩種踩法,加這道檢查之前兩種都印綠勾:
         (1) 把遊戲資料夾自己當成第二個參數(外部備份 = 遊戲本身);
         (2) ln -s attrib.dat attrib.dat.speedbak(備份指回正本)。
    """
    try:
        return os.path.samefile(str(a), str(b))
    except OSError:
        return False


def same_bytes(a, b, chunk=1 << 20):
    """兩個檔案內容是否完全相同。大小不同就直接回 False,不必整個讀。

    一次讀 1 MB(1 << 20)兩邊對一段,不把整個檔吃進記憶體:
    這張表上的 ingame.big 在本站的測試機上是 2.5 MB,沒有必要整個吃進來。
    兩邊同時讀到空字串,代表一起走到檔尾,那才回 True。

    回三種值不是兩種:True 一樣 / False 不一樣 / None 讀不出來。
    ⚠️ None 不可以當成 False —— 「讀不到」跟「不一樣」是兩件事,混為一談
       會讓人去追一個根本不存在的差異;而原本沒有 None 這一種的時候,
       一個 chmod 000 的備份檔會讓整份報告當場 traceback 斷在半路。
    """
    try:
        # 先比大小是為了快,不是為了省事:大小不同就一定不一樣,連開檔都不必。
        if a.stat().st_size != b.stat().st_size:
            return False
        # 全程 'rb'。這是檢查你的檔案時唯一的開檔動作,而且兩個都是唯讀。
        with open(a, 'rb') as fa, open(b, 'rb') as fb:
            while True:
                x = fa.read(chunk)
                y = fb.read(chunk)
                if x != y:
                    return False
                if not x:
                    return True
    except OSError:
        return None


def _run_capture(argv):
    """跑一次 main(),把畫面上的字接起來回傳。只有自我測試用得到。"""
    import contextlib
    import traceback
    buf = io.StringIO()
    saved = sys.argv
    sys.argv = argv
    try:
        with contextlib.redirect_stdout(buf):
            code = main()
    except Exception:
        # 噴例外本身就是一種失敗,但不可以連帶把後面幾個餌一起帶走 ——
        # 那會讓人以為只壞了一件事。回一個不可能的代碼,讓 check 去報。
        return 99, buf.getvalue() + '\n' + traceback.format_exc()
    finally:
        sys.argv = saved
    return code, buf.getvalue()


def _run_cli(argv):
    """跑一次 cli()(不是 main()),把畫面上的字接起來回傳。只有自我測試用得到。

    ⚠️ 為什麼不共用上面那支:那支叫的是 main(),而 Ctrl-C 與「掃描途中讀取
       出錯」這兩條路是外面的 cli() 接的。用 _run_capture 去測,cli() 的兩個
       except 一次都不會執行 —— 餌會綠,而綠的理由是它根本沒測到那道守門。
    ⚠️ 這裡接的是 BaseException 不是 Exception:Ctrl-C 丟的 KeyboardInterrupt
       不是 Exception 的子類。cli() 的守門哪天被拿掉,例外會從這裡浮上來;
       接住它,那個餌才會紅掉,而不是把整份自我測試一起帶走。
    """
    import contextlib
    import traceback
    buf = io.StringIO()
    saved = sys.argv
    sys.argv = argv
    try:
        with contextlib.redirect_stdout(buf):
            code = cli()
    except BaseException:
        return 99, buf.getvalue() + '\n' + traceback.format_exc()
    finally:
        sys.argv = saved
    return code, buf.getvalue()


def self_test():
    """種餌自我測試:每一道守門各放一個餌,抓不到就紅。

    為什麼要有:下面這幾道檢查(備份其實是正本本身 / 指不到東西的連結 /
    讀不出來 / 備份放在遊戲資料夾裡面)都是「沒事的時候完全不出聲」的那種。
    沒有餌的話,它們壞掉跟正常運作在畫面上長得一模一樣 —— 本站踩過這個坑。
    每一個餌都先確認「不下餌時不會亮」,再確認「下了餌一定亮」。

    ⚠️ 全程在系統暫存資料夾裡建自己的測試檔,跑完自己刪掉,
       不碰你的遊戲資料夾。這是整支腳本唯一會寫檔的地方。

    ⚠️ 在 python3 -O 底下不跑,直接以結束碼 2 收工(不回傳 True/False)。
    """
    # ⚠️ python3 -O 會把整份程式裡的 assert 整段拿掉。本站別支工具的自我測試是
    #    用 assert 判斷的,在 -O 底下那些餌一條都不會執行,螢幕上卻照樣一路 ✅ ——
    #    那是一片假的綠燈,比沒有測試更危險。
    #    這一支目前用的是下面那個 check() 不是 assert,所以 -O 還不會讓它假綠;
    #    擋掉是為了跟全站的自我測試一致,而且哪天這裡改寫成 assert,守門已經在了,
    #    不必指望那時候有人記得補。
    #    這裡直接結束整支程式,不回傳 True/False ——「測試沒跑」跟「測試沒過」
    #    是兩回事,用 1(有一項紅了)會把它們混在一起。
    if sys.flags.optimize:
        print('--selftest 不能在 python3 -O 底下跑:-O 會把 assert 整段拿掉,'
              '哪天這裡改用 assert 判斷就會變成一片假的綠燈。請拿掉 -O 再跑一次。')
        sys.exit(2)
    import shutil
    import tempfile

    print('自我測試(在系統暫存資料夾裡跑,不碰你的遊戲資料夾)')
    ok = [True]

    def check(label, cond, out=''):
        print('   %s %s' % ('✅' if cond else '❌', label))
        if not cond:
            ok[0] = False
            print('      實際印出來的是:')
            for line in out.strip().splitlines()[-12:]:
                print('      | %s' % line)

    def link(target, path):
        """做一個符號連結;這台機器不給做就回 False(Windows 常見)。"""
        try:
            os.symlink(target, path)
            return True
        except (OSError, NotImplementedError, AttributeError):
            return False

    td = tempfile.mkdtemp(prefix='mvp_check_backup_selftest_')
    try:
        root = os.path.join(td, 'game')
        vault = os.path.join(td, 'vault')
        rels = ['data/database/attrib.dat', 'data/database/pitcher.dat',
                'data/database/schedule.big', 'data/frontend/ingame.big']
        for d in ('data/database', 'data/frontend'):
            os.makedirs(os.path.join(root, *d.split('/')))
        os.makedirs(vault)
        for i, rel in enumerate(rels):
            with open(os.path.join(root, *rel.split('/')), 'wb') as f:
                f.write(bytes(bytearray([65 + i])) * (4096 + i))
            shutil.copy2(os.path.join(root, *rel.split('/')),
                         os.path.join(vault, os.path.basename(rel)))

        # 陰性對照:乾淨的一份必須全綠。它紅了,後面的餌抓到也不算數。
        code, out = _run_capture(['mvp_check_backup.py', root, vault])
        check('陰性對照:乾淨的資料夾 + 真的另一份備份 = 沒有紅字',
              code == 0 and '涵蓋全部' in out and '值得注意' not in out, out)

        db = os.path.join(root, 'data', 'database')
        att = os.path.join(db, 'attrib.dat')

        # 餌一:一份是真的複本、一份是指回正本的連結。兩者必須被分開講。
        shutil.copy2(att, att + '.facebak')
        if link('attrib.dat', att + '.speedbak'):
            code, out = _run_capture(['mvp_check_backup.py', root])
            check('餌一:指回正本的「備份」被指名,真的複本仍然說一樣',
                  '就是正本本身' in out and '跟現在的檔案一樣' in out, out)
            os.remove(att + '.speedbak')
        else:
            print('   ⏭ 餌一:這台機器不給做符號連結,略過')
        os.remove(att + '.facebak')

        # 餌二:名字在、卻指不到東西的備份,不可以在畫面上消失成「沒有備份」。
        pit = os.path.join(db, 'pitcher.dat')
        if link(os.path.join(td, 'nowhere.dat'), pit + '.bak'):
            code, out = _run_capture(['mvp_check_backup.py', root])
            check('餌二:指不到東西的備份會被指名,不會被當成沒有備份',
                  '指不到東西的連結' in out, out)
            os.remove(pit + '.bak')
        else:
            print('   ⏭ 餌二:這台機器不給做符號連結,略過')

        # 餌三:讀不出來的備份要照實說,而且整份報告不可以斷在半路。
        sch = os.path.join(db, 'schedule.big')
        shutil.copy2(sch, sch + '.bak')
        os.chmod(sch + '.bak', 0)
        readable = True
        try:
            with open(sch + '.bak', 'rb') as f:
                f.read(1)
        except OSError:
            readable = False
        # readable=True 代表「chmod 0 之後還是讀得到」(root、或不吃權限的
        # 檔案系統)。那種機器上餌三、餌七都測不到東西,誠實略過,不要假綠。
        if readable:
            print('   ⏭ 餌三:這裡的權限擋不住讀取(root 或該檔案系統),略過')
        else:
            code, out = _run_capture(['mvp_check_backup.py', root])
            # ⚠️ 餌要下在正確的地方:只驗「有沒有出現『讀不出來』」是抓不到
            #    「把 None 當成不一樣」這個寫法的 —— 那時候備註欄照樣會寫
            #    讀不出來。所以連「不可以說成內容不同」一起驗。
            check('餌三:讀不出來的備份照實說,沒被講成內容不同,報告也沒斷',
                  code == 0
                  and '讀不出來(權限不足或檔案有問題)' in out
                  and '跟現在的檔案不同' not in out
                  and '=' * 52 in out, out)
        os.chmod(sch + '.bak', 0o600)
        os.remove(sch + '.bak')

        # 餌四:備份資料夾放在遊戲資料夾裡面 —— 會跟遊戲一起消失。
        inner = os.path.join(root, 'backup')
        os.makedirs(inner)
        for rel in rels:
            shutil.copy2(os.path.join(root, *rel.split('/')),
                         os.path.join(inner, os.path.basename(rel)))
        code, out = _run_capture(['mvp_check_backup.py', root, inner])
        check('餌四:備份資料夾在遊戲資料夾裡面會被提醒',
              '在遊戲資料夾裡面' in out, out)
        shutil.rmtree(inner)

        # 餌五:把遊戲資料夾自己當成備份資料夾 —— 加這道檢查之前這裡全是綠勾。
        code, out = _run_capture(['mvp_check_backup.py', root, root])
        check('餌五:遊戲資料夾自己當備份時不會出現「涵蓋全部」',
              '就是遊戲裡的那個檔本身' in out and '涵蓋全部' not in out, out)

        # 餌六:【1】那一段的目標檔本身是壞掉的連結。
        cfg = os.path.join(root, 'controller.cfg')
        if link(os.path.join(td, 'nowhere.cfg'), cfg):
            code, out = _run_capture(['mvp_check_backup.py', root])
            check('餌六:目標檔是壞掉的連結時不會說成「還沒玩過遊戲」',
                  '這個名字在' in out and '玩過一次遊戲才會出現' not in out, out)
            os.remove(cfg)
        else:
            print('   ⏭ 餌六:這台機器不給做符號連結,略過')

        # 餌七:外部備份那一段也有同一組三態,要各自下餌 —— 【2】綠不代表【3】綠。
        if readable:
            print('   ⏭ 餌七:同上,權限擋不住讀取,略過')
        else:
            os.chmod(os.path.join(vault, 'attrib.dat'), 0)
            try:
                code, out = _run_capture(['mvp_check_backup.py', root, vault])
                check('餌七:外部備份讀不出來時不說成內容不同,也不說涵蓋全部',
                      code == 0
                      and '讀不出來(權限不足或檔案有問題)' in out
                      and '內容不同' not in out and '涵蓋全部' not in out, out)
            finally:
                os.chmod(os.path.join(vault, 'attrib.dat'), 0o600)

        # 餌八:同一個檔有兩份以上備份時,「讀不出來」不可以被算成「內容不同」。
        #       算錯的後果不是少講一句,是憑空多一句「最舊的那一份才是原廠檔」,
        #       把人指去還原一份它根本沒讀到的檔。先驗它該講的時候有講。
        shutil.copy2(att, att + '.bak')
        with open(att + '.speedbak', 'wb') as f:
            f.write(b'X' * 512)
        code, out = _run_capture(['mvp_check_backup.py', root])
        check('餌八(陽性對照):真的有兩份內容不同的備份時要講出來',
              '份內容不同的備份' in out, out)
        shutil.copy2(att, att + '.speedbak')      # 改成兩份一模一樣
        code, out = _run_capture(['mvp_check_backup.py', root])
        check('餌八(陰性對照):兩份一模一樣就不可以說成不同',
              '份內容不同的備份' not in out, out)
        os.chmod(att + '.speedbak', 0)
        if readable:
            print('   ⏭ 餌八:同上,權限擋不住讀取,略過最後一段')
        else:
            code, out = _run_capture(['mvp_check_backup.py', root])
            check('餌八:讀不出來的那一份不會被算成「內容不同的備份」',
                  '份內容不同的備份' not in out, out)
        os.chmod(att + '.speedbak', 0o600)
        os.remove(att + '.speedbak')
        os.remove(att + '.bak')

        # 餌九:報告跑到一半,某個檔剛好被別的程式刪掉/搬走(這是真的會發生的:
        #       雲端同步、防毒、另一支工具都可能在同一秒動它)。要製造這個時機
        #       需要兩個行程搶時間,所以這裡讓那個名字的 stat 在「問過一次之後」
        #       開始丟例外 —— 測的是「後面幾段還印不印得完」,不是 stat 本身。
        shutil.copy2(att, att + '.bak')
        with open(att + '.facebak', 'wb') as f:
            f.write(b'Y' * 512)
        shutil.copy2(att, att + '.playerbak')
        real_stat = Path.stat
        seen = [0]

        def flaky_stat(self, *a, **k):
            if self.name.endswith('.playerbak'):
                seen[0] += 1
                if seen[0] > 1:                 # 第一次還在,之後就不見了
                    raise OSError(2, 'no such file')
            return real_stat(self, *a, **k)

        Path.stat = flaky_stat
        try:
            code, out = _run_capture(['mvp_check_backup.py', root])
        finally:
            Path.stat = real_stat
        check('餌九:檔案在報告中途消失時,四段報告照樣印得完',
              code == 0 and '(讀不到)' in out and '=' * 52 in out, out)
        for s in ('.bak', '.facebak', '.playerbak'):
            os.remove(att + s)

        # 餌十:外部備份是完整的時候,結論不可以說「你也還沒有任何備份」——
        #       那句話跟【3】剛印的「涵蓋全部」在同一個畫面上互相打臉。
        code, out = _run_capture(['mvp_check_backup.py', root, vault])
        check('餌十:外部備份完整時,結論不會說「你也還沒有任何備份」',
              '涵蓋全部' in out and '你也還沒有任何備份' not in out, out)

        # ── 下面三道測的是 cli() 那兩道網,不是 main() 裡面的判斷 ──
        # 陰性對照要先跑:一道接得太寬的網,會把一次好好的檢查講成「讀取出錯」,
        # 而那種壞法在只有陽性餌的時候是全綠的。
        code, out = _run_cli(['mvp_check_backup.py', root, vault])
        check('餌十一(陰性對照):沒有中斷也沒有出錯時,cli() 照樣回 0',
              code == 0 and '涵蓋全部' in out
              and '已中止' not in out and '讀取途中出錯' not in out, out)

        # 餌十一:掃描途中按 Ctrl-C。這支全程唯讀,停在哪裡都不會留下半截的
        #        東西 —— 但它得把這句話講出來。加這道網之前,Ctrl-C 換來的是
        #        一整串 Python traceback(本站實測,結束碼一樣是 130),
        #        而讀者看到 traceback 的第一個念頭是「我把它弄壞了」。
        #        順便驗它不可以印出跑完才有的那句結論 —— 沒跑完就說跑完了,
        #        那是這道網最容易犯的錯。
        # ⚠️ 這裡用 raise 不用真的送 SIGINT:訊號由直譯器挑時機送達,寫成餌會
        #    偶爾落在別的地方,而偶爾紅一次的自我測試比沒有測試更難處理。
        #    真的送一次 SIGINT 的那一版另外測過,走的是同一條路,結果一樣。
        stat_before_ctrl_c = Path.stat

        def stat_ctrl_c(self, *a, **k):
            if self.name == 'ingame.big':       # 掃到第四個檔才中斷
                raise KeyboardInterrupt
            return stat_before_ctrl_c(self, *a, **k)

        Path.stat = stat_ctrl_c
        try:
            code, out = _run_cli(['mvp_check_backup.py', root])
        finally:
            Path.stat = stat_before_ctrl_c
        check('餌十一:掃描途中按 Ctrl-C 會講一句中文並以 130 收工,不噴 traceback',
              code == 130 and '已中止' in out and 'Traceback' not in out
              and '沒有建立、修改或刪除任何檔案' not in out, out)

        # 餌十二:掃描途中讀取出錯 —— 外接碟被拔掉、防毒把檔案鎖住、雲端同步
        #        把資料夾抽走,在這支唯讀的腳本上長成同一個樣子:某個呼叫丟
        #        OSError。size_of / when / mtime_of / same_bytes 這幾支自己
        #        接得住,但不是每一個呼叫都包得完,所以外面要有一道網。
        #        結束碼要跟「路徑打錯了」的 1 分開,不然批次檔判斷不出差別
        #        (本站實測:加這道網之前,兩種情形都是 1,而且都噴 traceback)。
        is_file_before_io_error = Path.is_file

        def is_file_io_error(self, *a, **k):
            if self.name == 'ingame.big':
                raise OSError(5, 'input/output error')
            return is_file_before_io_error(self, *a, **k)

        Path.is_file = is_file_io_error
        try:
            code, out = _run_cli(['mvp_check_backup.py', root])
        finally:
            Path.is_file = is_file_before_io_error
        check('餌十二:掃描途中讀取出錯會講一句中文並以 2 收工,不噴 traceback',
              code == 2 and '讀取途中出錯' in out and 'Traceback' not in out, out)

    finally:
        shutil.rmtree(td, ignore_errors=True)

    print('   %s 自我測試%s' % ('✅' if ok[0] else '❌',
                                '全部通過' if ok[0] else '沒過'))
    return ok[0]


def main():
    """照【1】到【4】的順序把四個問題各回答一次,全程唯讀,最後給一段結論。"""
    # --selftest 是這支唯一認得的旗標,而且它只在系統暫存資料夾裡跑,
    # 不碰你的遊戲資料夾。要放在濾掉旗標之前判斷,否則會被下一行吃掉。
    if '--selftest' in sys.argv[1:]:
        return 0 if self_test() else 1
    # 其餘 -- 開頭的東西濾掉:這支沒有 --apply 之類會動到檔案的旗標,
    # 但從別課複製指令過來的人很容易把字尾帶著。濾掉它,才不會被當成路徑。
    args = [a for a in sys.argv[1:] if not a.startswith('--')]
    # 沒給路徑就把檔頭那段說明當使用說明印出來。
    # 回傳 1 而不是 0:它沒有真的檢查過任何東西,不要讓外面誤判成「一切正常」。
    if not args:
        print(__doc__)
        return 1

    # 路徑常常是「把檔案拖進終端機」得到的,拖到的可能是 exe 或 .cfg,
    # 所以下面接受檔案,取它的上一層當遊戲資料夾。
    root = Path(args[0]).expanduser()
    if root.is_file():                       # 拖到 exe 或 .cfg 也接受
        root = root.parent
    if not root.is_dir():
        print('✗ 找不到這個資料夾:%s' % root)
        return 1

    vault = Path(args[1]).expanduser() if len(args) > 1 else None
    if vault is not None and not vault.is_dir():
        print('✗ 找不到你給的備份資料夾:%s' % vault)
        return 1

    print('遊戲資料夾  %s' % root)
    if vault:
        print('外部備份    %s' % vault)
    print('')

    # 一路收集「值得注意」的事,最後在【4】一次講完。
    # 邊走邊喊會讓畫面上的重點被四段報告沖散。
    notes = []

    # ── 1. 該備份哪些檔 ────────────────────────────────
    # 大小與修改時間都印出來,是為了讓你自己認得出「這個檔我上次動是什麼時候」。
    # 一份備份對不對得上某個時間點,只有你知道,腳本判斷不了。
    print('【1】本站教學會動到的檔案')
    total = 0
    present = []
    for rel, desc, lessons in TARGETS:
        p = root / rel
        if p.is_file():
            size = size_of(p)
            total += size or 0
            present.append((rel, p))
            print('  ✅ %-28s %12s  %s  %s'
                  % (rel, human(size), when(p), desc))
        else:
            print('  ✗  %-28s %12s  %-19s  %s'
                  % (rel, '(不存在)', '', desc))
            # is_file() 對「指向不存在的目標」的符號連結回 False,畫面上跟
            # 「真的沒有這個檔」長得一模一樣。用 lexists 把兩者分開:
            # 那個名字確實在,只是指向空的地方 —— 那是壞掉,不是沒有。
            if os.path.lexists(p):
                print('     ⚠ 這個名字在,但它是一個指不到東西的連結。')
                notes.append('%s 是一個指不到東西的連結' % rel)
            # ⚠️ controller.cfg 是玩過一次遊戲之後才會被建立的。
            #    一台剛安裝好、還沒玩過的機器沒有它是正常的,
            #    把它列成「值得注意」會讓第一次接觸的人以為自己裝壞了。
            elif rel in BORN_ON_FIRST_RUN:
                print('     (這個檔要玩過一次遊戲才會出現,現在沒有是正常的)')
            else:
                notes.append('找不到 %s' % rel)
    print('')
    print('  這 %d 個檔合計 %s(%s bytes)。'
          % (len(present), human(total), format(total, ',')))
    # ⚠️ 這裡不要寫「涵蓋 N 課裡的 M 課」。程式印出來的字最像事實,讀者不會
    #    去懷疑,而那兩個數字每加一課就過期。更不可以寫成「涵蓋所有會改檔的
    #    教學」——那是反方向的謊,照著做的人會以為備份好了,接著去跑換臉皮 /
    #    換球衣 / 改執行檔那幾課,而那些檔一份備份都沒有。
    print('  把它們複製到別的地方,只涵蓋上面列出的那幾課動的檔。')
    print('  ⚠️ 本站還有別的課動的是別的檔(models.big / datafile.big / mvp2005.exe /')
    print('     portrait.big / uniforms.big / fonts.big / .LOC / 語音檔 …),')
    print('     那些不在上面的清單裡。整包備份才是完整的做法,見「備份與還原 SOP」。')
    if not present:
        print('  一個都找不到 —— 你選的可能不是遊戲安裝資料夾。')
        print('  正確的資料夾裡面應該同時看得到 data 這個子資料夾和 .exe。')
        return 1

    # ── 2. 旁邊有沒有工具自動留的備份 ──────────────────
    # 本站的工具都把備份留在原檔旁邊、用「原檔名 + 備份副檔名」命名,
    # 所以只在同一個資料夾裡找,而且連原檔名一起比對:
    # 同一個 .bak 出現在不同檔案上,是不同的工具留的。
    print('\n【2】遊戲資料夾裡的備份檔')
    any_bak = False
    missing_bak = []
    for rel, p in present:
        cand = [p.parent / (p.name + s) for s in BAK_SUFFIXES]
        baks = [b for b in cand if b.is_file()]
        # 名字在、卻指不到東西的備份:is_file() 說 False,於是它在畫面上整個
        # 消失,讀者看到的是「沒有備份」。一個救不了你的名字擺在那裡,
        # 比沒有更危險 —— 所以要指名講出來。
        for b in [c for c in cand if not c.is_file() and os.path.lexists(c)]:
            print('  ⚠  %-28s 是一個指不到東西的連結,救不了你' % b.name)
            notes.append('%s 是一個指不到東西的連結,不是有效的備份' % b.name)
        if not baks:
            print('  ·  %-28s 沒有備份' % rel)
            if rel in NO_TOOL_BACKUP:
                print('     (本站沒有腳本會自動備份它 —— 請自己複製一份到別的地方)')
            # 先記著,最後再決定要不要算成「值得注意」——
            # 一台什麼都沒改過的機器本來就不該有備份,
            # 那不是問題,是還沒開始。
            missing_bak.append(rel)
            continue
        # 同一個檔可能被好幾支工具動過,每支各留自己那一份。全部列出來,
        # 因為「哪一份才是原廠檔」要靠時間先後判斷,少列一份就會挑錯。
        any_bak = True
        for b in baks:
            suffix = b.name[len(p.name):]
            if same_file(b, p):
                # 同一個檔:改正本的那一刻它跟著被改。這不是備份。
                mark, state = '⚠ ', '它就是正本本身,不是另一份'
                notes.append('%s 其實就是 %s 本身(同一個檔),不是備份'
                             % (b.name, p.name))
            else:
                eq = same_bytes(b, p)
                mark = '✅' if eq is not None else '⚠ '
                state = ('跟現在的檔案一樣' if eq is True else
                         '跟現在的檔案不同' if eq is False else
                         '讀不出來(權限不足或檔案有問題)')
                if eq is None:
                    notes.append('%s 讀不出來,無法確認它是不是有效的備份'
                                 % b.name)
            print('  %s %-28s %12s  %s  %s'
                  % (mark, b.name, human(size_of(b)), when(b), state))
            # 先查 (檔名, 副檔名) 這個組合,查不到再退回只看副檔名。
            # 順序不能反:.bak 這個副檔名同時屬於好幾課,只看副檔名會認錯人。
            owner = (BAK_OWNER.get((p.name, suffix))
                     or BAK_OWNER_BY_SUFFIX.get(suffix))
            print('     ↑ %s' % ('「%s」這一課建立的' % owner if owner
                                 else '不是本站工具建立的,可能是你自己複製的'))
        # 同一個檔有兩份以上備份時,它們是不同時間點的快照,不是同一份東西。
        # 每支工具只在第一次 --apply 時建立備份,所以最舊的那一份才是原廠檔。
        # 兩兩比對,而不是只比最舊跟最新:內容相同的多份備份(例如同一支工具
        # 重跑過)不算異常,只有真的存在內容不同的兩份時才值得把人叫住。
        if len(baks) > 1:
            pairs = [(baks[i], baks[j])
                     for i in range(len(baks)) for j in range(i + 1, len(baks))]
            # 用 is False,不用 not:same_bytes 讀不出來時回 None,
            # 而 not None 是 True —— 那會把「讀不到」講成「內容不同」。
            if any(same_bytes(x, y) is False for x, y in pairs):
                oldest = min(baks, key=mtime_of)
                print('     ⚠ %s 有 %d 份內容不同的備份 —— 它們是不同時間點的快照。'
                      % (p.name, len(baks)))
                print('       最舊的是 %s,它才是原廠檔;' % oldest.name)
                print('       其他幾份裡面已經含著前一次的修改。')
                notes.append('%s 有 %d 份內容不同的備份,原廠檔是 %s'
                             % (p.name, len(baks), oldest.name))
    if not any_bak:
        print('  一個備份都沒有。如果你還沒改過任何東西,這是正常的。')
        # 全部都沒備份 = 還沒開始動手,不是「值得注意」。
        # 這一段本來就已經印了「這是正常的」,再把每個檔各記一條
        # 進結論,等於自己打自己(實測:剛安裝好的原版會被說「有 5 項值得注意」)。
    else:
        # 有些檔備份了、有些沒有 —— 那才值得提醒。
        for rel in missing_bak:
            if rel in NO_TOOL_BACKUP:
                # 寫「沒有任何備份」會讓人去找是哪一支工具漏了,而答案是「沒有
                # 那一支」。講清楚要自己複製,這一項才是做得完的,不是永遠的紅字。
                notes.append('%s 沒有備份 —— 本站沒有腳本會自動備份它,'
                             '請自己複製一份到別的地方' % rel)
            else:
                notes.append('%s 沒有任何備份' % rel)

    # ── 3. 外部備份資料夾 ──────────────────────────────
    print('\n【3】外部備份資料夾')
    # 「外部備份是完整的」要留到結論那一段用:沒有這個變數的時候,一個檔案
    # 都不缺的人會在最後一行被告知「你也還沒有任何備份」,而【3】就在上面
    # 三行寫著涵蓋全部 —— 同一個畫面自己打自己。
    vault_ok = False
    if vault is None:
        print('  沒有給。要一起檢查的話,把備份資料夾當第二個參數:')
        print('    python3 %s "遊戲資料夾" "備份資料夾"'
              % os.path.basename(sys.argv[0]))
    else:
        # 備份放在遊戲資料夾裡面等於沒有備份:重灌、把整包刪掉、還原到某個
        # 時間點,兩份會一起消失。這一段只提醒,不改任何判斷。
        try:
            r = os.path.realpath(str(root))
            inside = os.path.commonpath([r, os.path.realpath(str(vault))]) == r
        except (OSError, ValueError):
            inside = False          # 不同磁碟機(Windows)會丟 ValueError
        if inside:
            print('  ⚠ 這個備份資料夾在遊戲資料夾裡面 —— 遊戲整包被刪掉或重灌時,')
            print('    它會跟著一起消失。請複製一份到別的地方,別顆硬碟更好。')
            notes.append('外部備份資料夾在遊戲資料夾裡面,會跟遊戲一起消失')
        # 少一個檔在還原那天才會發現,而那天通常來不及了,所以現在就記一條。
        miss = 0
        bad = 0
        for rel, p in present:
            # 兩種擺法都接受:保留原資料夾結構,或全部平放在同一層
            cand = [vault / rel, vault / p.name]
            b = next((c for c in cand if c.is_file()), None)
            if b is None:
                print('  ✗  %-28s 備份資料夾裡沒有' % rel)
                miss += 1
                notes.append('外部備份缺少 %s' % rel)
                continue
            if same_file(b, p):
                # 最兇的假安全:把遊戲資料夾自己當成備份資料夾,每一列都是綠勾。
                print('  ⚠  %-28s %12s  %s  這就是遊戲裡的那個檔本身,不是備份'
                      % (rel, human(size_of(b)), when(b)))
                notes.append('外部備份裡的 %s 就是遊戲裡的那個檔本身,'
                             '等於沒有備份' % rel)
                bad += 1
                continue
            eq = same_bytes(b, p)
            if eq is None:
                notes.append('外部備份裡的 %s 讀不出來,無法確認' % rel)
                bad += 1
            print('  %s %-28s %12s  %s  %s'
                  % ('✅' if eq is True else '⚠ ', rel, human(size_of(b)),
                     when(b),
                     '內容一致' if eq is True else
                     '內容不同(你改過了)' if eq is False else
                     '讀不出來(權限不足或檔案有問題)'))
        # ⚠️ 只有「一個都不缺、而且每一份都真的是另一份檔」才敢說涵蓋全部。
        #    少了 bad 這個條件,一個指回正本的連結會讓這行印出來。
        if miss == 0 and bad == 0:
            vault_ok = True
            print('\n  備份資料夾涵蓋全部 %d 個檔案。' % len(present))

    # ── 4. 結論 ────────────────────────────────────────
    # 結論只有三種狀態,不可以互相混:有事情要注意 / 什麼都還沒備份 / 都備份好了。
    # 混在一起講,等於把「你還沒開始」講成「你已經安全了」。
    print('\n' + '=' * 52)
    if notes:
        print('有 %d 項值得注意:' % len(notes))
        for i, n in enumerate(notes, 1):
            print('  %d. %s' % (i, n))
    elif not any_bak and vault_ok:
        # 遊戲資料夾裡沒有工具留的備份,但外部那份是完整的 —— 這種人是備份
        # 做得最好的那一種,不可以對他說「你還沒有任何備份」。
        print('遊戲資料夾裡沒有工具留的備份 —— 你還沒動手改,那是正常的。')
        print('你給的那個外部備份資料夾涵蓋了上面【1】列的每一個檔。')
    elif not any_bak:
        # ⚠️ 不要在這裡說「每一個都有備份」—— 一個都沒有的時候
        #    那句話是反方向的謊。沒有問題不等於已經備份好了。
        print('目前沒有需要處理的事 —— 但你也還沒有任何備份。')
        print('動手改之前,先把上面【1】列的那幾個檔複製到別的地方。')
    else:
        print('該備份的檔案都在,而且每一個都有備份。')
    print('\n本次檢查全程唯讀,沒有建立、修改或刪除任何檔案。')
    return 0


def cli():
    """main() 外面的最後一道網:接住 Ctrl-C 與掃描途中的讀取錯誤,回傳結束碼。

    為什麼不直接寫 sys.exit(main()):那樣的話,中途按 Ctrl-C 換來的是一整串
    Python traceback(本站實測),而讀者看到 traceback 的第一個念頭是
    「我把它弄壞了」。這支全程唯讀,停在哪裡都沒有東西會壞 —— 要講的就是這句。

    抽成一支函式、而不是寫在下面 `if __name__` 底下,是為了讓自我測試叫得到:
    掛在 `if __name__` 裡面的那幾行,除非另外開一個行程,否則沒有辦法下餌,
    而本站的腳本一律不開子行程。餌十一與餌十二測的就是這裡的兩個 except。
    """
    try:
        return main()
    except KeyboardInterrupt:
        # 這支從頭到尾沒有開任何檔案來寫,也沒有搬移或刪除,所以中途停掉
        # 就只是少印幾行。措辭限定在「你的遊戲資料夾與備份資料夾」:
        # --selftest 確實會在系統暫存資料夾裡建自己的測試檔,講成
        # 「沒有動到任何檔案」在那條路上就不精確了。
        # 結束碼 130 = 128 + SIGINT,跟其他幾種結束方式分得開。
        print('\n\n已中止。你的遊戲資料夾與備份資料夾全程唯讀,沒有動到任何檔案。')
        return 130
    except OSError as e:
        # 掃描途中檔案被移走、被鎖住,或整顆碟不見了會走到這裡:雲端同步、
        # 防毒隔離、外接碟斷線都算。裡面幾支(size_of / when / mtime_of /
        # same_bytes)自己接得住,這一道是接剩下的,讓報告不要以 traceback 收場。
        # 結束碼 2,跟「路徑打錯了」的 1 分開,批次檔才判斷得出差別。
        print('\n✗ 讀取途中出錯:%s' % e)
        print('  可能的原因:遊戲資料夾或備份資料夾正在被別的程式動')
        print('  (雲端同步、防毒掃描),或是外接碟中途斷線。')
        print('  關掉那些程式、確認碟還在,再跑一次。')
        print('  本次檢查全程唯讀,沒有建立、修改或刪除任何檔案。')
        return 2


if __name__ == '__main__':
    sys.exit(cli())

# ─────────────────────────────────────────────────────────
#  MIT License
#
#  Copyright (c) 2026 toni
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in
#  all copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
#  THE SOFTWARE.
# ─────────────────────────────────────────────────────────

📋 回報範本

卡住或做成了,複製下面這段填一填:

備份 status: ✅ / ❌
我備份了幾個檔: ___
檢查腳本的結論那一行(整段複製): ___
還原試過了嗎: 有 / 沒有
還原後檢查腳本說一樣嗎: 一樣 / 不一樣
異常訊息: ___

📌 重點整理

接下來