教學 › 中文版配大球場就跳出

中文版配大球場就跳出

很多人是退而求其次玩英文版的 —— 不是不想看中文, 是中文版一裝上比較大的球場就會在讀取條跑完的那一瞬間跳出,沒有任何錯誤訊息。 這件事卡了二十年,社群的結論是「球場檔不要超過 10MB」。 本站把執行檔反組譯到底,發現真正卡住的東西跟球場大小沒有關係, 而且兩個地方各改一個位元組就解決了。 這一頁把答案、完整的研究過程、以及我們走錯的十二條路全部寫出來。

難度

★☆☆ 一行指令

時間

約 3 分鐘

中文會不會少字

一個字都不會少
完全不動字型。一行指令可以還原。

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

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

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

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

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

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

  1. 下載 mvp_modernize.py從「下載」資料夾拖到桌面(沒拖會出現 can't open file),然後切到桌面
    cd ~/Desktop
  2. 先唯讀看你手上那顆是什麼。這一行最後空一格就停住,把 mvp2005.exe 拖進終端機視窗再按 Enter
    python3 mvp_modernize.py 
    看到「記憶體池 64 MB」就代表這顆可以改;看到「找不到」就是光碟原版,這一項做不到
  3. 預覽,還是不會動到檔案。本站量到的是 6 個位元組(2 個是記憶體池、4 個是檔頭校驗值;你的數字可能是 3 或 4,因為校驗值有幾格會剛好本來就對
    python3 mvp_modernize.py "<剛才那個路徑>" --pool 128
  4. 確定了才真的改,會先備份成 mvp2005.exe.modernizebak
    python3 mvp_modernize.py "<路徑>" --pool 128 --apply
  5. 完整的中文語系加上你最大的那座球場進遊戲。還是當就試 --pool 256;開不起來就立刻 --restore不要重複嘗試更大的值

後悔的話,這一行還原:

python3 mvp_modernize.py "〔路徑〕" --restore

Windows 的話:切到桌面改打 cd %USERPROFILE%\Desktoppython3 一律換成 python(說不是內部或外部命令就改打 py -3)。 第一次建議只做 --pool,三個參數一起改,萬一出事你分不出是哪一個造成的。 想知道那顆 64 MB 是怎麼找到的、以及本站走錯的十二條路,往下讀;只想複習,跳到📌 重點整理

答案先講

遊戲開機的時候會跟系統要一整塊記憶體,然後自己切給球場、字型、所有 .big 用。 那一塊有多大是寫死在執行檔裡的

007736F0  68 00 00 00 04    push 0x4000000     ← 67,108,864 = 64 MB
007736F5  e8 d2 a5 05 00    call <建立池子>
007736FA  59                pop  ecx
007736FB  c3                ret

2005 年切 64 MB 是照當年主機的額度來的。到了 2026 年, 這個數字沒有任何理由還要維持。04 改成 08 就是 128 MB, 中文版配大球場就再也不會跳出,而且中文一個字都不用砍。

實測(2026-08-28,班主任的機器)

記憶體池中文語系 + RFK 大球場當機位址
64 MB(原廠)❌ 跳出0x007D8423
64 MB(原廠,第二次)❌ 跳出0x007D8423(同一個)
128 MB可以玩
512 MB可以玩

兩處各只改一個位元組,現象消失,而且兩個不同的值都成立。 「當機」那一邊還重現了兩次、當在同一個位址。

症狀長什麼樣

而且它非常一致:

小球場大球場
英文語系
中文語系進得去❌ 跳出

兩個單獨都沒事,加起來才出事。這是整件事最重要的一條線索 —— 它代表卡住的東西不是「球場」也不是「字型」,而是兩者共用的某個東西

我們走錯的十二條路

這一節不是自我檢討,是這一頁最有用的部分。 每一條錯路都曾經看起來很合理,而其中好幾條是被本站自己的檢查工具本站自己的舊紀錄帶偏的。

① 「球場檔太大」(社群傳了二十年的說法)

本站先把那顆 RFK 清乾淨,從 15.14 MB 降到 8.51 MB (做法見另一課)。中文版還是當。 然後拿同一顆 8.51 MB 的檔案跑英文版 —— 完全正常。

⚠️ 2026-09-05 訂正:起點原本寫 14.95 MB,那是抄錯了包。 本站手邊兩包社群球場裡都有一顆叫 rfksnite.big,而且不是同一個版本: 標「2023_10MB」那包的是 15.14 MB、目錄指不到的位元組佔 45.0%, 清完剛好是 8.51 MB(本頁用的是這一顆);標「10MB」那包的是 14.95 MB、 孤兒 0.0%,那一顆清不動清孤兒那一課 表格裡「清完還是 14.95 MB」講的就是它。兩顆不要拿錯。

同一個檔案,一邊當一邊不當。那麼問題就不在那個檔案裡。

這條「中文版裝新球場一定會跳出」的說法,本站的班主任 2008 年就寫進模組安裝懶人包了。 當年那段原文,以及官方繁中版與英文版逐檔比對的結果,見 EA 自己的繁體中文版是怎麼做的

② 「是語系檔壞了」

中文 .LOC 裡確實有 168 條字串的格式符數量跟英文版對不起來 (那是真的會當的那種錯,本站也做了修正工具)。所以先測: 英文字型 + 中文語系檔

可以玩。所以語系檔是清白的,問題在字型。

③ 「那就把字型砍小」← 這條走了最久

開始二分測試,把中文字型一個一個換回英文,量「解壓後總和」:

fonts.big 解壓後總和結果
777,520✅ 可以玩(全英文)
1,394,064✅ 可以玩
2,037,648❌ 當機
2,726,912❌ 當機(全中文)

門檻確實框出來了,方向也「有效」—— 砍到某個量以下就能玩。 但砍字型就會缺字,而缺字正是大家不想玩中文版的原因之一。

轉折點:一句話問對了

班主任問了一句:「為什麼一定要瘦身字型,而不能擴充該擴充的設定?」

那一刻整件事轉向。砍字型是繞路,不是修好 —— 等於因為房間塞不下就丟家具,而不是去問「這個房間為什麼這麼小、能不能改大」。 後面所有進展都是從這一問開始的。

④ 「中文字型先天就只有 1,529 個字,一定會缺字」

這是本站寫的,而且是錯的。去把字型裡的字元表整張讀出來, 跟中文語系檔真正用到的字對帳:

語系檔用到的相異字元1,536
字型提供的字元1,529
語系檔要、字型沒有8 個

那 8 個是:不斷行空白、©®, 加上 這四個只出現一兩次、看起來是資料裡的壞位元組。

中文字型本來就是完整的。測試過程中看到的缺字, 百分之百是「把中文字型換成英文字型」換出來的。

⑤ 「六個中文字型檔完全一樣」

也是本站寫的,也是錯的。實際只有 frk12 / twc14 / twcnb 三個一樣, twc16 / twc18 / twn14 各自不同。

會寫錯是因為看到「解開後六個都是 287,072、壓縮後六個裡有四個是 183,942」就下結論 (另外兩個是 twc16 的 183,915 與 twn14 的 183,916,只差一兩個位元組)。 那正是本站自己列為 🔴 級的 「數量剛好對得上」陷阱, 而且是在剛引用完那條規則之後犯的。md5 一比就破。

⑥ 「執行檔被加密了,反組譯這條路是死的」

本站的內部紀錄寫著「執行檔的程式碼段被加密、熵 7.999」。 那對光碟安裝的原版成立,但對社群這顆不成立

執行檔大小程式碼段的熵有沒有保護區段
剛安裝好的原版6,972,0017.999stxt774 stxt371
社群 2023 版5,443,5846.575沒有

熵 6.575 是正常編譯出來的程式碼的數值。這一顆是明碼的,讀得動。 一句沒有標明適用範圍的舊紀錄,差點讓整條路被放棄。

⚠️ 2026-09-05 訂正:這兩格數字本站原本寫的是 7.998 與 6.365。 重新逐區段量過(把 .text 的原始位元組取出來算 base-2 的 Shannon 熵): 剛安裝好的原版是 7.9990,社群 2023 版是 6.5750。 那顆 6.365 在本站手邊任何一顆 mvp2005.exe 上都量不出來 (整檔的熵是 6.5296,其他區段是 .rdata 5.569、.data 4.120、 .data1 2.518、.rsrc 3.996),來源已經追不回去。 結論不受影響:一邊接近滿值、一邊是普通編譯碼,差距比原本寫的還大。

⑦ 「找到了!球場記憶體配額」

執行檔裡真的有這些字串,看起來就是凶手:

Stadium geometry memory quota exceeded.
PS2 stadium texture memory quota exceeded.
Enable Stadium Memory Quota

而且反組譯出來真的有配額常數 0xFA000(1,024,000)與 0x271000(2,560,000)。

但它們不會讓程式停下來。那一段做完比較之後只是把 「超出多少 K」用 sprintf 排成一句話畫到畫面上,接著就往下走了 —— 而且整支函式還被一個開發用的旗標擋著,正常玩根本不會執行。

⚠️ 這條差一點就被寫成「破案」。字串長得像凶手,反組譯出來只是個記事本。

⑧ 本站自己寫的診斷腳本,給出了錯誤的結論

為了看遊戲內建的除錯訊息,本站寫了一支腳本去攔截。跑完它印出:

log 總行數:297
⭐ 記憶體不夠(OUT OF MEMORY)     0 次
ℹ️ 有收到訊息,但沒有 OUT OF MEMORY。
   這也是答案:代表 64 MB 池不是當機原因。

這個結論是錯的。那 297 行全部是 Wine 自己的雜訊, 遊戲的訊息一行都沒收到 —— 連開機就會印的都沒有。 腳本的判斷只看「行數 > 5」,所以把「什麼都沒抓到」判成了「抓到了但沒有問題」。

一個測不到訊號的指標測出 0,是它自己的性質,不是資料的性質

判斷邏輯後來改成:先確認有沒有收到「遊戲開機一定會印」的那幾行, 沒有就判「這一輪不算數」,而不是判「沒問題」。 四條分支各用假 log 驗過一次。

附帶一提,第一次驗那四條分支的測試本身也是壞的 —— 測試檔寫到 /tmp/A,而那是一個已經存在的目錄,寫不進去, 於是兩條分支讀到了別的檔案。而輸出看起來還「通過」。

⑨ 「用 +debugstr 就攔得到」

攔不到。debugstr 這個頻道在 kernel32.dll 裡, 但實際實作 OutputDebugStringA 的是 kernelbase.dll, 而 kernelbase 的頻道清單裡沒有 debugstr。旗標打在空氣上。

後來從執行檔裡查到,EA 引擎真正的輸出開關是兩個環境變數 PRINT_DEVICEPRINT_CHANNEL。那才是正確的門。

⑩ 「當機位址就是鐵證」

當機的位址是 0x007D8423,反組譯出來是:

007D8400  mov  eax, [esp+4]              ← 目標位址
007D8413  movd mm0, edx                  ← 填值
007D8423  movntq qword ptr [eax], mm0    ← 對 eax 寫入,而 eax = 0

這確實就是「拿 NULL 去做記憶體填值」。但這不足以定罪 —— 那支填值常式是通用的 memset,全檔有 59 個呼叫點

真正能分辨的是填進去的那個值:配置失敗那條路填的是 0xBB55BB55, 而這個數字在整顆執行檔裡只出現一次。可惜當機時看不到暫存器, 所以這條線最後是「高度吻合但沒抓到現行犯」。

真正把它定案的不是這條,是後面那個對照實驗:兩個地方各改一個位元組,現象消失。

⑪ 「合併重複的字型可以省記憶體」

BIGF 的目錄沒有規定兩筆不能指向同一段資料,所以內容相同的字型可以共用一份, 字型一個都不少。這招做出來也驗過了。

要補一句:那個「少 28.8%」是在中文那份 fonts.big(1,279,340 個位元組)上量的,而且不是全部都來自合併。 合併走的是整份重新打包,目錄指不到的位元組會一起不見。中文那份少掉的 367,972 個位元組裡有 367,884 是合併省下來的; 換成 2009 台灣模組那份英文的(420,650 個位元組)就少 52.8%,而其中只有 14,525 是合併省下來的

但它省不到記憶體。反組譯顯示字型快取是照編號分開存的, 六個編號就是六份,跟檔案裡存幾份無關。它只是個好用的對照實驗。

⑫ 本站重寫了一支自己早就有的工具

做到「教大家改 4GB 跟解析度」這一步時,本站又寫了一支新工具 —— 然後才發現 mvp_modernize.py 早就在做這兩件事,而且做得更好(有預覽模式、逐位元組複驗)。

新工具已經刪掉,記憶體池的功能併進既有那一支一件事只留一個工具

真正的原因

遊戲啟動時做的事是這樣:

0x00719170  啟動主流程
0x007191AF    call 0x007736F0     ← push 0x4000000 (64 MB) → malloc → 登記成 0 號池「RAM」
              印出 "SKU Mem Initialized."

之後球場、字型、所有 .big 檔,全部從這一塊挖。 這是全遊戲唯一一塊固定大小的記憶體池。

而它挖不到的時候:

00778F4F  test esi, esi                      ← 配置結果是不是 NULL
00778F51  jne  0x778F61
00778F54  push "MemoryXAlloc(%s) OUT OF MEMORY"
00778F59  call <印訊息>
00778F5E  add  esp, 8                        ← 這裡沒有 jmp
00778F61  push edi / push 0xBB55BB55 / push esi(=NULL)
00778F68  call <填值常式>                     ← 對位址 0 寫入 → 當場當機

印完「記憶體不夠」之後沒有跳走,直接往下拿 NULL 去填值。 所以玩家看到的不是錯誤訊息,是直接回到桌面。 而且那行訊息走的是 OutputDebugStringA,一般玩家根本看不到。

為什麼中文特別貴:13.50 MB vs 0.37 MB

到這裡還有一個問題沒回答:中文字型也才 2.7 MB,怎麼會把 64 MB 撐爆?

答案在字型檔裡面。每個 .ffn 裡有一張「字圖集」, 就是把所有字排在一起的一張大圖。把兩邊的字圖集攤開比:

twc14格式代號字圖集尺寸每像素D3D 吃不吃得下
英文0x6D256 × 1282.00✅ 16 位元 4:4:4:4,直接用
中文0x791024 × 5120.504 位元,D3D8 沒有這個格式

Direct3D 8 沒有 4 位元的貼圖格式,所以中文字型的每一張字圖集 都必須在執行的時候整張展開成 32 位元才能送進顯示卡。 那一份展開後的複本,就是從那顆 64 MB 池裡挖的。

語系要展開的像素佔記憶體佔那顆 64 MB 池
中文3,538,94413.50 MB21.1%
英文96,5120.37 MB0.6%

差 36.7 倍。光是換上中文語系,那顆池子就先被吃掉五分之一, 剩下的空間再也塞不下一座大球場。這就是「兩個加起來才當」的真正原因。

⚠️ 順帶訂正本站自己:像素格式那頁 原本把 0x79 寫成「1×1 佔位圖」(那一頁已經在 2026-08-28 訂正過了, 現在寫的是 4 位元索引色、每像素 0.5 個位元組)。這裡它是一張 1024×512 的 4 位元圖。 那個「1×1」是當初取樣到的樣本剛好長那樣,不是格式的意義。

⚠️ 這一頁量的是哪一份中文字型包

市面上的中文化不只一種,而它們動的槽位不一樣:

哪一種字型檔名結尾幾個語系檔
社群包(本頁量的)_en12 FEENG.LOC / IGENG.LOC,蓋掉英文
EA 官方繁體中文版_jp13 FEJPN.LOC / IGJPN.LOC,放進日文槽位

所以本頁的數字跟字型速查那頁的數字不一樣是正常的, 兩邊量的是不同的包。

為什麼一定要「占用某個現成語系」?因為執行檔裡組字型檔名的那張跳表 只有四種後綴_en(case 0、1)、_sp(2)、 _jp(3、4)、_es(5)—— 沒有中文(跳表在 0x004FE9BC,本站自己反組譯驗過)。 中文化只能借別人的位子。

那 4GB 補丁呢?那是另一件事

幾乎每個玩這款的人都套過 4GB Patch。它救不了這個當機,而且原因很清楚。

4GB(大位址)記憶體池
它在哪PE 檔頭的一個位元程式碼裡的一個 push
它管什麼程式最多能用到多少位址空間(2 GB → 4 GB)遊戲實際跟系統要了多大一塊(64 MB)
改了會怎樣房間變大櫃子變大

塞不下東西是因為櫃子小,不是因為房間小。 把房間從 2 坪擴到 4 坪,櫃子還是那個櫃子。 這就是為什麼二十年來大家都套了 4GB,中文版配大球場還是照當。

兩件事都要做,但它們互不取代。4GB 與解析度的完整說明在 在 2026 年把它跑起來那一課。

哪些執行檔可以改

三件事的適用範圍不一樣,因為它們住在檔案的不同地方:

執行檔大小4GB解析度記憶體池
剛安裝好的英文原版6,972,001
剛安裝好的中文原版7,525,175
社群 2023 版5,443,584

為什麼會這樣:

工具會自己判斷並拒絕,不會亂改。看到 「找不到記憶體池的樣態」就是你手上這顆屬於上面那兩排。

本站不教怎麼處理光碟保護。如果你手上只有原版執行檔, 這一課的記憶體池部分你就是做不到 —— 這是本站做不到的事,不是你的問題。

👉 你要做的事

每一步的指令都給兩行:上面那行是 Windows,下面那行是 Mac。 只跑你自己那一行。不會開終端機的話,先看 新手基本功第 1 課。

1
下載腳本,放到桌面,切到桌面

mvp_modernize.py(零相依,同一支也管 4GB 與解析度)

cd %USERPROFILE%\Desktop
cd ~/Desktop
按了下載之後,檔案跑到哪裡去了?

瀏覽器多半會直接放進「下載」資料夾,所以按完之後還有一步:

1打開檔案總管(Mac 用 Finder),左邊點「下載
2找到 mvp_modernize.py
3把它拖到桌面

沒做這一步的話,後面執行會出現 can't open file —— 那不是你打錯,是檔案還在「下載」裡。

2
先看你手上那顆是什麼 —— 完全唯讀,不會改任何檔案

會印出架構、記憶體上限、記憶體池大小、以及解析度表的八筆內容。

python mvp_modernize.py 
python3 mvp_modernize.py 

a上面那行最後空一格就停住,先不要按 Enter
b用檔案總管(Mac 用 Finder)找到遊戲的 mvp2005.exe把那個檔案本身拖進終端機視窗(不是資料夾)。
c按 Enter。

Windows 的遊戲資料夾通常長這樣:

C:\Program Files (x86)\EA SPORTS\MVP Baseball 2005\mvp2005.exe

看到「記憶體池 64 MB」就代表這顆可以改。 看到「找不到」就是你手上這顆是光碟原版,這一項做不到(原因見上一節)。

3
預覽 —— 還是不會動到檔案

它會把要改的位元組位置列出來給你看(畫面上最多列前 16 個,總數以它印的那一句為準;這一項本站量到 6 個,全部列得出來),你確認了才寫。

python mvp_modernize.py "<剛才那個路徑>" --pool 128
python3 mvp_modernize.py "<剛才那個路徑>" --pool 128

本站量到的是 6 個位元組,拆開來是:

  • 2 個是記憶體池。它在檔案裡有兩處,每一處都是一個 4 位元組的數字, 但 64 MB 改成 128 MB 只動得到最高位那一格,所以兩處各 1 個、合計 2 個。
  • 4 個是檔頭校驗值0x000180 起算的那 4 格)。 改完內容就跟著重算。Windows 載入一般程式時並不檢查這一欄, 重算是為了工整;工具的規則是「原本填 0 的就維持 0,原本有值的才重算」, 不會替你在空欄位裡填一個新東西進去(兩份剛安裝好的原版這一欄都是 0, 細節見另一課pe_checksum() 那一列)。

⚠️ 你看到的數字不一定是 6,那是正常的。 記憶體池那 2 個一定在;校驗值那 4 格裡有幾格剛好本來就等於新值, 要看你手上是哪一顆執行檔。本站在同一台上拿九顆不同的 mvp2005.exe 各跑一次 --pool 128 預覽:其中六顆找得到記憶體池, 總數 3、4、6 都出現過;另外三顆是還帶著光碟保護的原版, 連記憶體池都找不到,工具直接停住不做事。 要核對的是「記憶體池那兩個位置有沒有出現」,不是總數等不等於 6。

4
確定了才真的改

會先備份成 mvp2005.exe.modernizebak, 寫完再讀回來確認「改動的位元組剛好是預期那幾個,其他一個都沒動」。

python mvp_modernize.py "<路徑>" --pool 128 --apply
python3 mvp_modernize.py "<路徑>" --pool 128 --apply
順便一起做 4GB 跟解析度?可以

三個參數可以同時給,它會一次算完再一起寫:

python mvp_modernize.py "<路徑>" --4gb --resolution 1920x1080 --pool 128 --apply
python3 mvp_modernize.py "<路徑>" --4gb --resolution 1920x1080 --pool 128 --apply

第一次建議只做 --pool。三個一起改, 萬一出事你分不出是哪一個造成的。

5
完整的中文語系加上你最大的那座球場進遊戲

這一步不能跳。本站驗得到「檔案是對的」,驗不到「遊戲讀得動」。

如果你平常會多開幾顆執行檔(mvp2005 - 4GB.exe 之類), 記得你實際雙擊的是哪一顆就改哪一顆。改了 A 卻跑 B 是最常見的白工。

✅ 成功標準

❌ 出錯處理

看到什麼怎麼做
「找不到記憶體池的樣態」你這顆是光碟原版,程式碼加密,這一項做不到
「開頭不是 MZ / 找不到 PE 檔頭」路徑要指到 mvp2005.exe 本身,不是資料夾
python 不是內部或外部命令Windows 改打 py -3 mvp_modernize.py ...。兩個都不行就照 新手基本功裝 Python
can't open file腳本還在「下載」資料夾,沒拖到桌面
改完遊戲開不起來立刻 --restore,然後回報。不要重複嘗試更大的值
128 MB 還是會當--pool 256。還是當就回報 —— 那代表不只這一個原因

還原

python mvp_modernize.py "<路徑>" --restore
python3 mvp_modernize.py "<路徑>" --restore

📋 回報格式

作業系統:Windows 11 / Windows 10 / macOS ___
執行檔大小:___
唯讀檢查的「記憶體池」顯示:___
設成多少:___ MB
中文語系 + 大球場:進得去 / 還是當
中文有沒有缺字:完整 / 缺字
球場檔名與大小:___
附上的 log(有跑 mvp_wine_log.sh 才有):mvp_debug_<...>_可回報.log(遮蔽版)

還沒解決的部分

這一頁的結論是靠對照實驗成立的(改一個變數、現象消失、兩個值都成立), 不是靠抓到現行犯。以下是本站沒有量到的:

本頁用到的其他工具

📌 重點整理

這支腳本在做什麼

這一課附了三支腳本。其中會動你檔案的是兩支mvp_font_swap.pymvp_fix_loc.py),流程是同一個形狀:先讀、再比、再預覽,你點頭了才寫。 這兩支的修改都是預設只看不改,加上 --apply 才會動到你的檔案,而且動之前一定先備份。 寫完再讀回來對一次三條 --apply 都有:mvp_fix_loc.py --fix --applymvp_font_swap.py --swap --applymvp_font_swap.py --dedupe --apply 都會把剛寫出去的檔重讀一遍逐項逐位元組比對,對不上就停下來、回非 0 的離開碼,不會再叫你拿那個檔進遊戲; mvp_fix_loc.py 那一條還會自己拿備份把檔案還原回去 (備份留著不刪,因為自動還原本身也可能失敗)。 只有還原是例外--restore 不看 --apply,打了就直接把備份蓋回去,所以「動之前一定先備份」這句話對它不成立,它是反過來把備份用掉 —— 但蓋之前會先驗那份備份完不完整、蓋完還會跟備份逐位元組比一次,比過了才把備份刪掉。 第三支 mvp_wine_log.sh 只是啟動遊戲並把畫面上的訊息錄成一份 log,一個位元組都不寫進遊戲資料夾,所以上面這些規則跟它無關。

mvp_font_swap.py 管的是 data/fonts/fonts.big。 它先把封裝檔的目錄拆開,問每一個字型「你解開之後有多大」, 再把你現在用的那份跟英文語系那份並排列出來;你指定要換哪幾個, 它就把那幾段換成來源檔裡的版本,整份重新打包寫回去。 換字型只是手段,真正的目的是一次換一組、進遊戲試一次,把兇手二分出來。

mvp_fix_loc.py 管的是 data/IGENG.LOCdata/FEENG.LOC。 它把中文版與英文版照字串編號逐條對帳,挑出「中文要的參數比英文多」的那些 (本站在測試機上量到 168 條),只把多出來的格式符拿掉, 中文字一個都不動。

mvp_font_swap.py(換字型)

哪一段做什麼為什麼要有它
paths() 算出兩條路徑:會被改的 data/fonts/fonts.big,以及拿來換過去的來源檔。來源檔照一張清單找第一個存在的,fontsFonts 兩種大小寫都試。 英文語系排在中文語系前面,因為英文版是「不會當」的那一邊,換過去才算是在減少變數。大小寫要兩種都試,是因為中文化模組是在 Windows(檔名不分大小寫)上做出來的,搬到 Mac 或 Linux 就會咬人。
read_big() BIGF 封裝檔的目錄,回傳「整份位元組 + 每一筆的(名字, 位移, 長度)」。檔頭固定 16 個位元組,目錄接在後面,一筆一筆排。 後面每一件事都要靠這張目錄。這裡也藏著一個讀者看不出來的事實:同一個檔頭裡兩種位元組序都有,位移 0x04 的總長度是小端序,而項目數與每一筆的位移長度是大端序。搞錯任何一個,讀出來就是天文數字。
qfs_size() 不解壓,直接讀 QFS 檔頭宣告的「解開後有多大」(開頭是 10 FB,緊接著 3 個位元組、大端序)。 全站要比的是「解開後的總量」,不是檔案裡壓縮過的長度,因為吃記憶體的是解開後那一份。而既然只要那個數字,就完全不需要解壓:少寫一整套解壓器,也就少一種把資料弄壞的可能。
cmd_show() 不加任何旗標時跑的就是它。左邊列「現在這份的每個字型解開後多大」,右邊列「來源檔的同名字型多大」,最後標出換過去會多還是會少。一個位元組都不寫。 動手之前要先看得見。找不到來源檔它也不會停,只是右邊留白:沒得比,但至少看得到現況。
cmd_swap() 把你指定的那幾個字型換成來源檔裡的版本。名字只認底線前面那一段、不分大小寫(檔案裡叫 au20b_en.ffn,你打 au20b 就好)。沒加 --apply 只印預覽。 這是整支腳本的主角。兩邊逐位元組相同的會直接跳過,因為沒有效果的改動不值得動檔案,更不值得為它生出一份備份、把「最早那一份原檔」的位置佔掉。
write_big() 整份重新打包:算出目錄多長、空出檔頭那一段、把每一段資料依序接在後面(段與段之間補到 4 的倍數)、最後把檔頭連目錄整塊蓋回去。 要等資料都放完才知道每一段落在哪,所以檔頭一定最後寫。要注意的是它會丟掉目錄指不到的位元組。本站量了手邊 5 份 fonts.big:中文那 3 份(各 1,279,340 bytes)目錄指不到的只有 103 個位元組而且全是 0x00;2009 台灣模組那 2 份英文的(各 420,650 bytes)目錄指不到 207,698 個位元組,其中 101,872 個不是 0x00。所以備份不是客套話。2026-09-05 起寫回去也是原子的:先寫同一個資料夾裡一個名字帶亂數的暫存檔、fsync 真的落到碟上、讀回來逐位元組對過,才用 os.replace 換上去。所以寫到一半斷電、外接碟被拔掉、按了 Ctrl-C,你的 fonts.big 維持原樣。
cmd_dedupe()
write_big_dedup()
把內容一模一樣的字型合併成共用一份:目錄照樣每個名字一筆,只是多筆指到同一段資料。字型數量不變、解開後總量也不變。但它跟上一列的 write_big() 一樣是整份重新打包,目錄指不到的位元組同樣會不見,所以檔案少掉的不會只有合併省下來的那些。本站量同樣那 5 份 fonts.big:中文那 3 份少 367,972 個位元組(其中 367,884 是合併省下來的);2009 台灣模組那 2 份英文的少 222,202 個位元組,其中只有 14,525 是合併省下來的,其餘是目錄指不到的那一段。 它不是為了省硬碟,是為了做一個乾淨的對照實驗:檔案變小而解開後的資料總量完全不變。當機的上限如果卡在檔案大小,這樣就會好;卡在解開後的資料,就不會好。兩種結果都告訴我們答案在哪一層。2026-09-05 之前這條路一執行就停在 NameError,現在修好了,見下面那一段。
_atomic_copy() 備份的實作:先整份複製到一個名字帶亂數的暫存檔(跟備份放同一個資料夾),fsync 真的落到碟上,再讀回來跟原檔對過 SHA-256,才用 os.replace 換成正式名字。 直接複製成 .fontbak 的話,複製到一半被中斷(磁碟滿、外接碟被拔、按了 Ctrl-C)會留下一個半截的備份,而下一次執行看到它「存在」就會沿用。兩段式之後,髒掉的是那個暫存檔,「備份已經存在」那個判斷永遠不會被騙。2026-09-05 訂正:暫存檔的名字本站原本寫的是 .part,那是猜得到的名字 —— 有人先在那裡擺一個指向資料夾外面的符號連結,複製就會沿著它去覆寫外面那個檔。現在改成隨機名字(O_CREAT|O_EXCL 開檔,先佔著也建不起來),而且動手前會先擋掉符號連結。
cmd_restore() .fontbak 蓋回 fonts.big,然後把備份檔刪掉。蓋之前會先驗備份完不完整:走的是本檔裡那個 _restore_from_backup()(0 個位元組的備份、BIGF 檔頭宣告長度跟實際長度對不上的備份,都會被擋下來,而且正本一個位元組都不會動)。蓋上去之後再跟備份逐位元組比一次,比過了才刪備份 刪備份是刻意的:留著會讓下一次 --swap 誤以為「已經備份過了」而沿用一份已經沒有意義的檔。還原完等於回到出發點,下次改會重新備份一次。
⚠️ 2026-09-05 訂正:這一格本站原本寫「它不驗備份完不完整」「還原是單程票」,那是修好之前的行為,已經作廢。當時量到的是:把備份截成八分之一的 159,917 bytes 再 --restore,正本就被蓋成那 159,917 bytes,畫面照樣印「✅ 已還原」、離開碼還是 0。現在同一個餌的結果相反:本站 2026-09-05 在暫存複本上重跑(正本是那份 fonts.big 跑過一次 --dedupe --apply 之後的 911,368 bytes、備份截成 159,917 bytes),畫面印「這份備份是壞的,不敢拿它覆蓋…」、離開碼 1,正本的 SHA-256 一個位元組沒變,那份截斷的備份也還留著。

mvp_fix_loc.py(修格式符)

哪一段做什麼為什麼要有它
parse() .LOC 語系檔的三段結構:LOCH 檔頭、LOCI 索引段(每筆前 2 個位元組是字串編號)、LOCL 文字段(每筆 4 個位元組是位移,字串本體是 UTF-16LE、兩個 0x00 結尾)。全部小端序。 這裡有一個讀者絕對看不出來的事實:位移是相對於 LOCL 那四個字的開頭算的,不是相對於檔案開頭。把它當成檔案位移,讀出來就是一堆亂碼。字串長度也沒有記在任何地方,結尾就是那兩個 0x00
analyse() 整支腳本唯一在做判斷的地方。把中文版與英文版逐條對帳,挑出「中文要的參數比英文多」的條目。 對帳是照字串編號配對,不是照順序:兩邊的條數與排列可以不同,照順序比會整排錯位,而錯位之後每一條看起來都對不上。編號在英文版裡找不到的直接跳過,沒有基準就不判。
strip_fmt() 把一條字串裡的格式符砍到只剩前幾個,格式符之間的文字原封不動接回去。 這就是「中文字一個都不會動」那句話的實作:中文與標點全部原樣接回去。留下來的是最前面那幾個,不是挑型別最像英文版的那幾個。⚠️ 要注意被拿掉的不一定只有 % 開頭那幾個字元 —— 旗標那一類裡含一個半形空白,所以 %1 in %2 這種寫法會連空白帶字母一起被當成一段吃掉(見下面 CFMT 那一列;本站量到的這對中文語系檔上這種命中是 0 個)。
CFMT(那一行正規表示式) 界定「什麼才算格式符」:% 之後可以有旗標與寬度精度,最後一個字元一定要是 printf 認得的轉換字元。 EA 自己也用 %,但用的是 %2 %6 這種替換符,由遊戲的字串引擎處理,不會餵給 printf26 不在轉換字元那張表裡。⚠️ 但旗標那一類裡含一個半形空白,所以「% 加數字、一個空白、再接一個剛好是轉換字元的英文字母」會被連成一段吃掉:%1 in %2 會命中 %1 i。本站在剛安裝好的英文原版那份 data/ 上量到這種命中 FEENG.LOC 90 個、IGENG.LOC 9 個(Checking %3 for %8 Saves. 一條就中兩個:%3 f%8 S)。手邊這份中文語系檔(FEENG 6,436 條 / IGENG 1,663 條)目前一個都沒被誤判:含空白的命中 0 個,FEENG.LOC 只有 7 個是 % 加數字開頭,全是合法的 %02d%4dIGENG.LOC 一個都沒有;FEENG.LOC 那 5 條含 %% 的也確實一個都沒命中。把空白從旗標裡拿掉重跑同一對檔,要修的那 168 條一條不差,所以受影響的只有說明,不是這對檔上的結果。
rebuild() 把改好的字串寫回去:檔頭、索引段、位移表整塊原樣搬過去,只重寫位移表之後的字串本體,然後就地把位移表裡的數字改成新的。 字串編號、條數、順序全都不變,遊戲照編號要字串一定還要得到。而且字串長度沒有記在別的地方,所以句子變短之後只要位移跟著改,檔案就自然變小,不需要補空白對齊。2026-09-05 起寫回去不再是直接開著玩家的 .LOC 覆蓋:先寫同一個資料夾裡一個名字帶亂數的暫存檔,再用 os.replace 換上去,正本永遠不會停在半截狀態。
cmd_fix() 預覽或真的修。加了 --apply 才會走:備份(只做第一次)→ 重寫 → 重讀複驗,複驗沒過就自己拿備份還原(備份留著不刪)並回非 0 的離開碼。兩個語系檔各自備份、各自複驗。備份如果已經存在就沿用,而沿用之前會先驗那份備份完不完整 —— 而且兩個語系檔的舊備份是在寫第一個檔之前一起驗完的,驗不過就整支停下來、一個位元組都不寫。動筆之前還會先擋掉符號連結:正本或備份是連結就整支停下來。 複驗要對三件事:條數一樣、每一條的字串編號一個沒變、每一條內容跟打算寫進去的完全相同。「寫完就當作成功」是這類工具最常見的謊,而這是玩家的遊戲檔,不能靠猜的。2026-09-05 補強:那三件事各自產生一句失敗原因、全部參與判定,所以畫面會直接說是哪一項對不上;而且以前複驗沒過只印一個 ❌ 就照樣回 0 —— 看輸出的人以為成功了,壞掉的檔卻留在遊戲資料夾裡。
_restore_from_backup() 還原之前先驗備份完不完整:順著 LOCH 的結構走一遍,LOCL 要在檔頭指到的位置、位移表不能被切掉、最後一條字串的位移不能超出檔尾。 2026-08-30 上線前的稽核抓到的真漏洞:拿一份被截成八分之一的備份去還原,416,753 個位元組的語系檔會變成 52,094,而且畫面上印「已還原」。檔案被截斷時最後一條字串的位移一定會指到檔案外面,所以這一關擋得住。2026-09-05 補上另一半還原的寫入本身也變成原子的 —— 先寫同資料夾一個名字帶亂數的暫存檔、fsync、權限比照正本、讀回來跟備份比長度與 SHA-256,過了才 os.replace 換上正本。以前這裡是一行 shutil.copy2(),而 copy2 的第一件事就是把正本截成 0 個位元組再一路寫;前面那幾道把關驗的全是「備份好不好」,一道都擋不住「複製到一半斷掉」
_reject_symlink()
_atomic_replace()
動筆之前先看正本與備份是不是符號連結,是的話整支停下來、一個位元組都不寫。所有暫存檔都用 tempfile.mkstemp目的檔那個資料夾裡開一個名字帶亂數的檔(O_CREAT|O_EXCL,先佔著也建不起來),寫完 fsync、對過長度與 SHA-256,才 os.replace 換上去。 這兩件事對讀者都是新的。暫存檔以前叫 <備份檔>.part,那是可以事先預測的名字:有人先在那裡擺一個指向遊戲資料夾外面的符號連結,工具就會沿著它去覆寫外面那個檔。而判斷用的是 os.path.islink 不是 os.path.exists —— 指向不存在目標的連結在 exists() 眼中是 False,但 open(..., 'wb') 照樣會沿著它去建立、覆寫外面那個檔。
cmd_restore() 把兩個 .LOC 的備份蓋回去,蓋完先跟備份逐位元組比過才刪那一份備份,比不過就把備份留著。兩個檔各走各的:其中一份備份壞掉不會把另一份一起帶走,好的那份照樣還原,最後才用非 0 的離開碼收尾並說清楚哪一個沒還原。 只還原「找得到備份」的那些:只修過其中一個檔的話,另一個本來就沒有備份可以還原,那不是錯誤。本站實測過完整的一圈(看 → 預覽 → --apply--restore),還原後兩個檔跟原檔逐位元組相同

兩支的安全網大部分是同一套:會動你檔案的那幾條預設只看不改寫之前一定先備份而且備份只做第一次(所以重複執行也回得到最原始那一份)、一行指令還原但有一件事兩支不一樣,先講清楚(原本是三件,另外兩件已經在 2026-09-05 抹平,下面一起說明)。 會改你的檔案 一、「預設只看不改」不包含還原。兩支的 main() 都是先判斷 --restore--apply 完全不參與那一條路:打了 --restore 就直接寫檔,寫完還會把備份刪掉,所以「動之前一定先備份」對它不成立,它是反過來把備份用掉。本站 2026-09-03 在暫存複本上實測:mvp_font_swap.py 沒加 --apply,1,279,340 位元組的中文語系 fonts.big 就被蓋成備份那份的 397,327 位元組,.fontbak 隨即消失;mvp_fix_loc.py 沒加 --apply,兩個語系檔就被蓋回備份的 60,023(IGENG.LOC)與 416,753(FEENG.LOC)位元組,兩個 .locbak 也一起消失。這是刻意的設計(還原本來就該一步到位),只是站上原本那句話把它一起蓋了進去,所以在這裡講明。2026-09-05 補一句:兩支的 --restore 現在都是先驗備份、原子寫入、蓋完再跟備份逐位元組比一次,比過了才刪備份;驗不過或比不過就把備份留著、回非 0 的離開碼。所以「把備份用掉」這件事仍然成立,但它不會拿一份壞備份把你的正本蓋掉。 ⚠️ 2026-09-05 訂正:這裡原本還列了第二件與第三件不一樣的事,那兩件現在都不成立了。 原本的第二件是「還原之前驗不驗備份,兩支不一樣」—— 當時只有 mvp_fix_loc.py 會驗;現在兩支都驗mvp_font_swap.py--restore 也走 _restore_from_backup() 了,細節在上面那張表的 cmd_restore() 那一列)。mvp_fix_loc.py 那一邊的實測仍然成立:測試機那份 416,753 位元組的 FEENG.LOC,備份刻意截成前八分之一的 52,094 位元組,--restore 印「這份備份是壞的」、回傳碼是 1,正本一個位元組都沒被動。 原本的第三件是「寫完立刻重讀複驗,三條 --apply 裡只有兩條有」—— 少的那一條是 --dedupe --apply;現在三條都有,對不上就停下來、回離開碼 2,不會再叫你拿那個檔進遊戲。 做不到的事也講清楚:mvp_font_swap.py 不會生字型,只能在你電腦上已經有的兩份 fonts.big 之間互搬;mvp_fix_loc.py 不會翻譯也不會補字,而且只比格式符的數量、不比型別(本站沒驗過型別對不對得上)。 ⚠️ 2026-09-05 訂正:--dedupe 那條路本站原本寫「目前跑不起來」,現在修好了。當時 cmd_dedupe() 裡用到一個那個函式沒有的名字,一執行就停在 NameError;要講清楚的是它不是「連檔案都還沒讀到」就停,前一行的 read_big() 已經把整份 fonts.big 讀進記憶體、目錄也解完了,而從進那個函式到那一行只有讀沒有寫,所以它當時弄不壞任何東西,只是做不到。現在同一條路跑得完:本站 2026-09-05 拿中文那份 1,279,340 bytes 的 fonts.big 實跑 --dedupe --applyfrk12_entwc14_entwcnb_en 三個各 183,942 bytes 的字型合併成一份、檔案變成 911,368 bytes、解開後總量 2,726,912 沒變、字型數量 12 → 12,而且輸出最後多了「複驗:12 個項目全部逐位元組相同 ✅」那一行 —— 也就是本站原本寫的「這條路沒有複驗,修好之後也還是沒有」同樣已經作廢。--swap--restore、不加旗標那三條本來就正常。 2026-09-05 還多了兩件事,兩支都有中途按 Ctrl-C 會明確告訴你遊戲檔有沒有被動到 —— 程式記著 os.replace 到底做過幾次,所以它講的是「什麼都沒動」還是「已經改了,用 --restore」, 離開碼一律 130mvp_fix_loc.py 以前這條路是安靜地結束、還回 0 的。 另一件是 --selftest:它自己造一份最小的檔案,把上面每一道把關正反各測一次 (反向的那些是「故意做一件必須失敗的事」),不需要遊戲資料夾也不碰任何遊戲檔。 mvp_font_swap.py 的遊戲資料夾因此變成選填,所以它「什麼參數都不給」的離開碼是 1(印用法), 跟「檔案或參數有問題」的 2 分開;mvp_fix_loc.py 的遊戲資料夾仍是必填,那一支不給參數還是 2。

完整原始碼

這一課的主角是 另一課mvp_modernize.py(那一頁有它的完整原始碼)。 下面附的是本課自己的字型工具 mvp_font_swap.py跟你剛才下載的那一支一個字都沒有不同(本站有自動檢查在守這件事)。

展開 / 收合完整原始碼(2007 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

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

"""
mvp_font_swap.py —— 一個一個換字型,找出到底是哪一個把記憶體吃爆

    先看現況        python3 mvp_font_swap.py "<遊戲資料夾>"
    預覽要換什麼    python3 mvp_font_swap.py "<遊戲資料夾>" --swap au20b,Tw24
    真的換過去      python3 mvp_font_swap.py "<遊戲資料夾>" --swap au20b,Tw24 --apply
    還原            python3 mvp_font_swap.py "<遊戲資料夾>" --restore
    自我測試        python3 mvp_font_swap.py --selftest

⚠️ 上面第二行**只是預覽**,一個位元組都不會寫。要真的換,同一行最後面
   加上 --apply(第三行)。這兩行只差三個字,所以特地分開寫。

⚠️ 只改 data/fonts/fonts.big,而且一定先備份成 .fontbak,--restore 一行還原。

─────────────────────────────────────────────────────────
 這在解什麼
─────────────────────────────────────────────────────────
2026-08-28 的對照實驗(toni 實機):
**同一個球場檔(清到 8.51 MB,逐位元組相同),英文版進得去,中文版讀取條讀完就當。**

→ 「球場超過 10MB 就當」這條傳了二十年的規矩 **被這一場實驗推翻了**。
   清到 8.51 MB 照樣當,所以問題不在檔案大小。

→ 而中英文的差別被壓縮到 **三個檔**:`FEENG.LOC`、`IGENG.LOC`、`fonts.big`。
   兩個文字檔中文版還比較**小**,只有字型檔變大:

       字型解開後   英文 777,520  →  中文 2,726,912   (多 1.95 MB)

→ 而「讀取條讀完」那一刻,正是球場載入完、要把畫面的字型交出去的時候。

⚠️ **本站沒有證明「字型吃爆記憶體」就是主因。** 量到的是上面那些數字加上
   toni 的對照實驗。機制那一段還是推論 —— 這支腳本就是拿來驗它的。

─────────────────────────────────────────────────────────
 為什麼可以只換一部分
─────────────────────────────────────────────────────────
中文化那份 fonts.big 裡有 12 個字型,但**不是每個都需要中文**:

    字型         中文版字元數    解開後大小     裡面有中文嗎
    au20b            98         526,560       ❌ 沒有(最肥的一個!)
    Tw24             33         132,192       ❌ 沒有
    dflt             95          67,168       ❌ 沒有
    hrdbg           158          72,544       ❌ 沒有(跟英文版逐位元組相同)
    mini1/mini2     267      70,240/135,776   一點點
    frk12 · twc14/16/18 · twcnb · twn14
                   1529         287,072 各    ✅ 這六個才是中文字型

**`au20b` 只有 98 個字元卻佔 526,560** —— 每個字 5,373 bytes,是其他字型的 30 倍。
它不是中文字型,是**做得很大的英文字型**。把它換回英文版的那一份,
一次省 508,560 bytes,而**中文一個字都不會少**。

─────────────────────────────────────────────────────────
 輸入 / 輸出 / 安全網 / 做不到的事
─────────────────────────────────────────────────────────
輸入
  · 一個位置參數:遊戲安裝資料夾(它的下一層才是 data)
  · 會被改到的檔只有一個:<遊戲資料夾>/data/fonts/fonts.big
  · 拿來換過去的「來源字型」自動照這個順序找第一個存在的:
        data/2023英文語系/fonts   data/英文語系/fonts   data/2023英文語系/Fonts
        data/中文語系/Fonts       data/中文語系/fonts
    英文那幾條排在前面,因為英文版是「不會當」的那一邊。
    也可以用 --source 直接指定一個 fonts.big。

輸出
  · 不加旗標           只在畫面上列表(每個字型現在解開後多大、換過去會差多少),
                       一個位元組都不寫
  · --swap / --dedupe  沒加 --apply 就只印預覽,一樣不寫檔
  · --swap --apply     重寫 data/fonts/fonts.big,寫完立刻重讀,逐項逐位元組
                       比對一次,結果印在「複驗」那一行
  · --dedupe --apply   重寫 data/fonts/fonts.big,印「合併的組 / 檔案大小 /
                       解開後總量 / 字型數量」四項,寫完也會重讀複驗
                       (2026-09-05 補的;在那之前這條路沒有這一關)
  · --restore          把 .fontbak 蓋回去,再把備份檔刪掉。**不需要 --apply**,
                       打了就直接寫
  · --selftest         自己造一份最小的 BIGF 來測,不看遊戲資料夾、
                       不碰任何遊戲檔。全過印一行、回 exit code 0。
                       ⚠️ 在 python3 -O 底下會直接拒絕跑、回 exit code 2:
                       -O 會把 assert 全部拿掉,跑下去會印出假的綠燈

安全網
  · --swap 與 --dedupe 預設就是預覽。沒有 --apply 就一個位元組都不會落地。
    (反向對照:--swap au20b 不加 --apply,fonts.big 前後 SHA-256 相同,
     也沒有生出 .fontbak。)
  · ⚠️ --restore 不吃上面那一條,它根本不看 --apply。main() 的判斷順序是
    --restore → --dedupe → --swap,--apply 完全不參與 --restore 那一條路:
    只打 --restore 就會把 .fontbak 蓋回 fonts.big,蓋完還把備份刪掉,
    所以「動之前一定先備份」對它不成立,它是反過來把備份用掉。
    2026-09-03 在暫存複本上實測:正本放中文語系那份 fonts.big(1,279,340 bytes)、
    備份放本機 data/Fonts 那份(397,327 bytes),只打 --restore(沒有 --apply)之後
    正本就變成 397,327 bytes、與備份逐位元組相同,.fontbak 也不見了。
  · 第一次寫入前備份成 fonts.big.fontbak,做法是「先寫一個同資料夾的唯一
    暫存檔(tempfile.mkstemp,名字猜不到也不可能被事先佔位)→ fsync →
    跟原檔對 SHA-256 → os.replace 換名」,中途被中斷不會留下半截備份。
    (2026-09-05 之前用的是 fonts.big.fontbak.part 這種猜得到的名字;
     那個名字要是被人先放上一個指向資料夾外面的符號連結,寫下去會先把
     外面那個檔截斷。現在名字是隨機的,而且動手前會先擋掉符號連結。)
  · **寫遊戲檔本身也是同一套**:--swap / --dedupe 的 --apply 不是直接
    open(fonts.big, 'wb'),而是先寫暫存檔、讀回來逐位元組對過,才 os.replace
    換上去。所以寫到一半斷電、拔碟、按 Ctrl-C,你的 fonts.big 維持原樣,
    不會變成半截檔。(2026-09-05 之前是直接寫的。)
  · 正本或備份的名字如果是符號連結,兩條 --apply 與 --restore 都會停下來,
    什麼都不寫。順著連結寫下去,被改到的會是連結另一頭那個檔。
  · 按 Ctrl-C 時印出來的那句話**不會說謊**:換名與登記被綁成一段不可中斷的
    區間(_NoInterrupt),外加「還沒動 / 正在換 / 已換」三態登記當保險。
    已經換過就承認換過並印還原指令,exit code 一律 130。
    (2026-09-06 之前:Ctrl-C 剛好落在換名與登記中間,會印「跟動手前一模一樣」,
     而正本的 SHA-256 其實已經變了。)
  · 備份已經存在就沿用、不覆蓋,所以重複執行不會把最早那一份原檔洗掉。
  · --swap --apply 寫完會重讀複驗;對不上就**停下來**(印一行中文、exit code 2),
    不會再叫你拿這個檔進遊戲,也不會回 exit code 0 讓包裝腳本誤判成功。
  · --dedupe --apply 從 2026-09-05 起也有這一關:寫完一樣重讀、逐項逐位元組
    比對,對不上就停下來(exit code 2)。在那之前它只印四項統計就結束。
  · --restore 會先驗備份再蓋回去,走的是本檔的 _restore_from_backup:
    0 bytes 的備份、BIGF 檔頭宣告長度跟實際長度對不上的備份(截斷一定對不上),
    都會被擋下來,而且**正本一個位元組都不會動**。蓋完再逐位元組比對一次,
    比對過了才把備份刪掉、才印成功;比對沒過就留著備份、印一行中文、exit code 2
    (備份在第一關就被擋下來的那一種,訊息走 stderr、exit code 1)。
    2026-09-05 在暫存複本上實測(2026-09-03 的舊紀錄寫的是修這一條之前的行為,
    已作廢):本站測試機那份中文版 fonts.big(1,279,340 bytes)跑過一次
    --swap au20b --apply(來源用本機 data/Fonts 那份 397,327 bytes 的 fonts.big,
    正本變 1,232,260 bytes),備份留著原本那 1,279,340;把備份截成八分之一的
    159,917 bytes 再 --restore,印「這份備份是壞的,不敢拿它覆蓋…」、
    exit code 1,正本仍是 1,232,260 且 SHA-256 一個位元組沒變,截斷的備份也還在。
    換成完整的備份再跑一次,正本回到 1,279,340,與動手前逐位元組相同。

做不到的事(先講,免得跑完才發現)
  · 不會生字型。只能在「你電腦上已經有的兩份 fonts.big」之間互搬,
    來源檔裡沒有的字型就換不了。兩邊的名字要一模一樣才換得動:2026-09-05
    實測剛安裝好的原版中文版那份(1,683,513 bytes / 13 項)名字是 _jp 結尾、
    原版英文那份(204,820 bytes / 12 項)是 _en,這兩份互相換不了,
    腳本會停下來說「來源檔裡沒有 au20b_jp.ffn」。
  · 前綴命中兩筆時不會替你猜。同一份原版中文版裡 dflt_en.ffn 與 dflt_jp.ffn
    並存,只打 dflt 會停下來要你把完整名字打出來(完整名字也認)。
  · 只碰 data/fonts/fonts.big。語系檔(FEENG.LOC / IGENG.LOC)、執行檔、
    球場檔一個都不動。
  · 不解 QFS 壓縮。上面那些「解開後大小」是讀 QFS 檔頭自己宣告的數字,
    不是解壓算出來的。
  · 不會直接告訴你兇手是誰。它只讓你一次換一組、每換一組進遊戲試一次,
    答案要你自己二分出來。
  · ⚠️ 「字型吃爆記憶體」這個前提,本站到現在也還沒證明(見上一段)。
  · --dedupe 2026-09-05 之前一執行就 NameError,現在修好了(經過是寫在
    cmd_dedupe 的說明裡)。它做的事跟 --swap 不一樣:不會讓「解開後合計」
    變小,所以它驗的是另一個問題,不是二分測試的替代品。

MIT License · Copyright (c) 2026 toni · 無外部相依,Python 3.7 以上
"""

import argparse
import hashlib
import os
import shutil
import signal
import struct
import sys
import tempfile

# ── 備份的原子性(2026-08-29 上線前稽核加)────────────────────────────
# 原本是直接 shutil.copy2(遊戲檔, .bak)。複製途中被中斷(磁碟滿、外接碟拔掉、
# Windows 上按 Ctrl-C)會留下一個**半截的 .bak**;下一次執行看到它「存在」
# 就印「備份已存在,保留最早那一份」繼續改遊戲檔,之後 --restore
# 會拿那個半截檔覆蓋掉正本。
#
# 實測(2026-08-29):把 2,665,562 bytes 的備份截成 300,000 bytes,
# 本站防護最嚴的那支還原指令三道把關全過、印「✓ 已從備份還原」、exit code 0,
# 2.66 MB 的遊戲檔當場被 300 KB 蓋掉。magic 只看開頭,看不出後面少了多少。

def _reject_symlink(path, what):
    """目的檔是符號連結就停下來,不要順著它寫下去。

    2026-09-05 資安稽核抓到的形態:如果 <目標>.part 或 <目標> 本身是一個指向
    資料夾外面的符號連結,`open(..., 'wb')`/`shutil.copy2()` 會**先把連結指到
    的那個檔截斷**,之後的 os.replace 才換掉連結本身 —— 外面那個檔已經被寫壞了。

    ⚠️ 不能用 os.path.exists() 判:符號連結存在、但它指到的檔不存在時
       (dangling symlink),exists() 回 False,等於整道防線失效。
       os.path.islink() 走的是 lstat,不跟著連結,dangling 的也認得出來。

    ⚠️ 只擋**檔案本身**,不擋它上面那幾層資料夾 —— 這是刻意的:
       macOS 的 /tmp 本身就是指向 /private/tmp 的符號連結,外接碟、
       家目錄裡的捷徑也常常是。連資料夾一起擋會把一大票正常的安裝擋在門外。
       真正要防的那一招(有人先在猜得到的暫存名字上放好連結)已經被
       _new_temp 的 O_CREAT|O_EXCL 擋住,不必靠父資料夾檢查。
    """
    path = os.fspath(path)
    if os.path.islink(path):
        try:
            tgt = os.readlink(path)
        except OSError:
            tgt = '(讀不出來)'
        raise DataError(
            '%s 是一個符號連結(指向 %s),本工具不動它。\n'
            '     順著連結寫下去,被改到的會是連結另一頭那個檔。\n'
            '     請先把它換成真正的檔案,或換一個乾淨的遊戲資料夾再跑。'
            % (what, tgt))
    return path


def _new_temp(dst, tag):
    """在 dst 同一個資料夾裡開一個唯一的暫存檔,回傳 (檔案描述子, 路徑)。

    為什麼不用 dst + '.part' 這種猜得到的名字(2026-09-05 改掉的):
      · 猜得到 ⇒ 別人可以先在那個名字上放一個指向別處的符號連結,
        我們一 open 就把外面那個檔截斷了(見 _reject_symlink)
      · mkstemp 用 O_CREAT|O_EXCL 開檔,那個名字要是已經被佔(檔案也好、
        符號連結也好)就換一個,絕不可能寫到別人先放好的東西上

    放在**同一個資料夾**是 os.replace 要原子就必須同一個檔案系統;
    丟到 /tmp 再搬過來,跨磁碟時就退化成「複製 + 刪除」,又回到會留半截檔的老路。
    """
    dst = os.fspath(dst)
    d = os.path.dirname(os.path.abspath(dst))
    return tempfile.mkstemp(dir=d, prefix='.%s.%s-' % (os.path.basename(dst), tag))


def _sha256(path):
    """整份檔案的 SHA-256。用來確認「寫出去的」跟「來源」逐位元組相同。

    一次讀 1 MB,不把整份載進記憶體 —— 這個函式也會被拿去對執行檔那種大檔。
    """
    h = hashlib.sha256()
    with open(path, 'rb') as f:
        for chunk in iter(lambda: f.read(1 << 20), b''):
            h.update(chunk)
    return h.hexdigest()


# 「正本已經被換上新內容了嗎」—— os.replace 真的做過才會記進來。
# 只記遊戲正本,不記 .fontbak:備份多一份不會害到任何人,
# 而 Ctrl-C 的訊息要回答的是「我的遊戲檔到底動了沒有」。
# 第二個欄位記的是「換成什麼」,因為「改過了」跟「已經還原了」之後
# 該給的建議剛好相反(前者要 --restore,後者已經在原點了)。
_WRITTEN = []


def _mark_written(path, why):
    _WRITTEN.append((os.fspath(path), why))


# 「正在換 X」這個中間態(2026-09-06 加)。
#
# ⚠️ 為什麼一份 _WRITTEN 不夠:os.replace 把遊戲檔換成新的之後,還要再跑一行
#    _mark_written() 才算登記過。Ctrl-C 剛好落在這兩行中間的話,收尾讀到的
#    _WRITTEN 是空的 —— 它會照舊狀態印「還沒有換過任何檔案」,而磁碟上那個檔
#    其實已經換過了。**那句話是假的**,而看到那句話的人不會去 --restore。
#
# 所以現在是三態:
#    · 不在 _REPLACING 也不在 _WRITTEN → 還沒動
#    · 在 _REPLACING 而不在 _WRITTEN   → 正在換,不知道換完了沒有
#    · 在 _WRITTEN                     → 已換
# 換名之前先進 _REPLACING,換完並登記進 _WRITTEN 之後才退出來。
_REPLACING = []


class _NoInterrupt(object):
    """把「os.replace + 登記」包成一段不會被 Ctrl-C 切開的區間。

    這段期間收到的 SIGINT 先記著不處理,離開這段之後再照常丟出 KeyboardInterrupt。
    所以收尾看到的登記,一定跟磁碟上的狀態一致:要嘛「還沒換、也沒登記」,
    要嘛「換過了、也登記了」,不會卡在中間那個會說謊的窗口。

    ⚠️ 這**不是**「按了沒用」:訊號只是被延後到那兩行結束,離開這個區塊照樣
       丟 KeyboardInterrupt,exit code 一樣是 130。
    ⚠️ signal.signal 只能在主執行緒裝。裝不上去(非主執行緒等情況)就退回
       原本的行為 —— 不會比以前更糟,而且還有上面那個三態登記兜著。
    """

    def __enter__(self):
        self._pending = False
        self._old = None
        try:
            self._old = signal.signal(signal.SIGINT, self._remember)
        except (ValueError, OSError):   # 非主執行緒等情況:退回原本行為,不會更糟
            self._old = None
        return self

    def _remember(self, signum, frame):
        self._pending = True

    def __exit__(self, exc_type, exc, tb):
        if self._old is not None:
            try:
                signal.signal(signal.SIGINT, self._old)
            except (ValueError, OSError):
                pass
        if self._pending and exc_type is None:
            raise KeyboardInterrupt
        return False


def _replace_and_register(tmp, path, why, doing):
    """把暫存檔換成正本,並且登記 —— 這兩件事之間不可以被 Ctrl-C 切開。

    why   進 _WRITTEN 的講法(已經發生的事,例如「換成新的內容」)
    doing 進 _REPLACING 的講法(正在發生的事,例如「把它換成新的內容」)
    """
    path = os.fspath(path)
    key = (path, doing)
    # 「登記進去了沒有」不能用旗標判,要用 _WRITTEN 自己的長度:同一支腳本
    # 一次執行裡可能對同一個檔換好幾次(--selftest 就是),用 in 判會被上一次
    # 留下的同一筆騙到。長度變長 = 這一次真的登記進去了。
    n_before = len(_WRITTEN)
    _REPLACING.append(key)
    try:
        with _NoInterrupt():
            os.replace(tmp, path)      # 這一行是原子的:換上去,或什麼都沒發生
            _mark_written(path, why)
    except OSError:
        # 換名丟 OSError 就是**沒有**換成,狀態是明確的,可以放心撤掉「正在換」。
        _REPLACING.remove(key)
        raise
    finally:
        # ⚠️ 這裡是 finally 不是 else:_NoInterrupt 在區塊**結束時**才補丟那顆
        #    被延後的 KeyboardInterrupt,走 else 的話就永遠跑不到,已經誠實
        #    登記成「已換」的檔會被同時掛在「正在換」上(2026-09-06 餌 15 抓到的)。
        if len(_WRITTEN) > n_before:
            _REPLACING.remove(key)
    # ⚠️ 登記沒進去、又不是 OSError(例如 signal.signal 裝不上去時漏進來的
    #    KeyboardInterrupt)就刻意**不撤**「正在換」:那種時候真的不知道換名
    #    做完了沒有,收尾就該照實說「正在替換」,不可以說「沒動到」。


def _atomic_write(path, data):
    """把 data 變成 path 的新內容 —— 要嘛整份換上,要嘛 path 一個位元組都沒動。

    2026-09-05 之前 write_big / write_big_dedup 是直接 open(遊戲檔, 'wb') 寫下去的:
    那一行執行的瞬間遊戲檔就被截成 0,寫到一半斷電/拔碟/Ctrl-C,玩家拿到的是半截檔。
    現在改成「先寫同資料夾的唯一暫存檔 → fsync → 讀回來逐位元組對過 → os.replace」,
    對不上就把暫存檔刪掉、正本原封不動。

    順序不能換:**複驗排在 os.replace 之前**,所以「寫壞了」的那一份根本沒有機會
    成為玩家的遊戲檔。
    """
    path = os.fspath(path)
    _reject_symlink(path, '%s' % path)
    fd, tmp = _new_temp(path, 'new')
    try:
        with os.fdopen(fd, 'wb') as fo:
            fo.write(data)
            fo.flush()
            os.fsync(fo.fileno())          # 真的落到碟上,不是只到系統快取
        if os.path.exists(path):
            # 保留正本原本的權限。mkstemp 開出來的是 0600,
            # 直接換上去會讓遊戲檔從「大家讀得到」變成「只有我讀得到」。
            shutil.copymode(path, tmp)
        with open(tmp, 'rb') as f:
            back = f.read()
        if back != data:
            raise DataError(
                '剛寫出去的暫存檔讀回來跟記憶體裡的不一樣(%d bytes vs %d bytes)。\n'
                '     磁碟可能滿了或有問題。%s 一個位元組都沒有動,暫存檔已刪掉。'
                % (len(back), len(data), path))
        # 換名 + 登記綁成不可切開的一段:Ctrl-C 不可以卡在這兩件事中間,
        # 不然收尾會照舊狀態說「還沒有換過任何檔案」(理由見 _REPLACING)。
        _replace_and_register(tmp, path, '換成新的內容', '把它換成新的內容')
    except BaseException:
        # 接 BaseException 不是 Exception:Ctrl-C 與 SystemExit 都不是 Exception
        # 的子類,漏掉它們就會在玩家的資料夾裡留下暫存檔垃圾。清完照樣往上丟。
        try:
            os.remove(tmp)
        except OSError:
            pass
        raise


def _atomic_copy(src, dst):
    """備份要嘛完整、要嘛不存在 —— 中間狀態不會留在 dst 這個名字上。

    ⚠️ 本站有些腳本用 pathlib.Path 存路徑,有些用字串。
       2026-08-29 第一版寫成 dst + '.part',在 Path 上直接 TypeError,
       等於所有備份都失敗 —— 而且「半截備份被擋下來」那個測試照樣是綠的。
       是陰性對照(先證明正常流程真的會產生備份)抓到的。

    ⚠️ 2026-09-05 再改一次:暫存檔的名字從**猜得到的** dst + '.part'
       換成 tempfile.mkstemp(dir=備份要放的那個資料夾)。理由與實測見
       _new_temp 與 _reject_symlink 的說明 —— 猜得到的名字可以被事先放上
       一個指向資料夾外面的符號連結,那樣被截斷的會是外面那個檔。
       同一輪也補上「備份寫完先 fsync,再跟來源對一次 SHA-256」:
       對不上就不換名,.fontbak 這個名字上永遠不會出現半截檔。
    """
    src, dst = os.fspath(src), os.fspath(dst)
    _reject_symlink(dst, '備份檔 %s' % dst)
    fd, part = _new_temp(dst, 'part')
    try:
        # 先整份複製到一個「別人不可能事先佔位」的暫存名字。
        # 被中斷的話髒掉的是那個暫存檔,不是 .fontbak,
        # 所以「備份已經存在」那個判斷永遠不會被半截檔騙到。
        with os.fdopen(fd, 'wb') as fo:
            with open(src, 'rb') as fi:
                shutil.copyfileobj(fi, fo)
            fo.flush()
            os.fsync(fo.fileno())
        shutil.copymode(src, part)
        # 備份是最後一道退路,所以不只比大小,整份對 SHA-256。
        if _sha256(src) != _sha256(part):
            raise DataError(
                '備份寫出去之後跟原檔對不起來(%s),沒有換上 %s。\n'
                '     磁碟可能滿了或有問題。原檔一個位元組都沒有動。'
                % (src, os.path.basename(dst)))
        # ⚠️ 這一行**沒有**包 _NoInterrupt,是刻意的:備份不進登記表
        #    (理由見 _WRITTEN 那一段),所以這裡沒有「換好了卻還沒登記到」
        #    這個窗口 —— 沒有窗口就沒有謊可以說。要包的是正本那兩處。
        os.replace(part, dst)          # os.replace 是原子的
    except BaseException:
        # 這裡接的是 BaseException 不是 Exception,
        # 因為 Ctrl-C(KeyboardInterrupt)與 SystemExit 都不是 Exception 的子類,
        # 漏掉它們就會在使用者的資料夾裡留下暫存檔垃圾。清完照樣往上丟。
        try:
            os.remove(part)
        except OSError:
            pass
        raise


def _restore_from_backup(bak, dst):
    """還原之前先擋掉明顯壞掉的備份。

    ⚠️ 這裡**不能**比對「備份與目標大小相同」—— 本站多數腳本是把資料接到
    檔尾來改檔(專案鐵律:封裝檔不可重新打包),改完之後正本本來就比備份大,
    那樣比會擋掉每一次合法的還原。

    ── 2026-08-30 補上三道(上線前資安稽核抓到的真漏洞)────────────────
    原本只有「不是 0 bytes」+「BIGF 檔頭宣告長度」兩道。**BIGF 以外全破。**
    實測拿「前 1/8 的半截備份」去還原,六支腳本把正本吃掉而且都印成功:
        mvp_fix_loc / mvp_menu_text   .LOC        416,753 →  52,094
        mvp_edit_speed / mvp_ratings
        / mvp_player                  attrib.dat  840,643 → 105,080
        mvp_modernize                 mvp2005.exe 5,443,584 → 680,448
                                      (它還印「複驗:內容與備份相同 ✅」)
    最後那個會讓遊戲**完全開不起來**,而站上每一課都寫著「隨時可以 --restore」。

    現在檢查五件事:
      1. 備份不是 0 bytes
      2. BIGF:檔頭第 4-8 個位元組宣告的總長度要等於實際長度
         (兩種位元組序都接受;哪些檔是大端、各有幾個,以 reference/bigf.html 量到的為準,這裡不寫會過期的數字)
      3. LOCH(語系檔):檔頭指到的 LOCL 要在檔內,而且最後一條字串的位移
         也要在檔內 —— 截斷之後那個位移一定會超出去
      4. MZ(執行檔):PE 節區表裡 raw offset + raw size 的最大值不得超過檔案長度
      5. **通用地板**:非 BIGF 的備份不得小於「要被蓋掉的那個檔」的一半。
         非 BIGF 的工具都是原地改(大小幾乎不變),所以這條很安全;
         BIGF 走 append 會越改越大,所以刻意**不套**這條,由第 2 道負責。

    ⚠️ 2026-09-05 訂正:2026-08-30 那次補說明寫的是「本檔目前沒有任何地方呼叫
       這個函式」—— 那句話已經不成立了。這一支的 cmd_restore 從此走這一條,
       跟同一課的 mvp_fix_loc.py 一樣。在那之前它是直接把備份讀出來蓋回去的,
       實測拿截成八分之一的備份去 --restore,1,232,260 bytes 的正本被 159,917
       bytes 蓋掉、畫面照樣印「✅ 已還原」、備份還被刪掉,exit code 還是 0。
    """
    # 本檔開頭已經 import 過 struct,這裡再寫一次不影響行為;
    # 好處是這一整個函式可以原封不動複製到別支腳本,不必記得補 import。
    import struct
    bak, dst = os.fspath(bak), os.fspath(dst)
    if not os.path.exists(bak):
        raise SystemExit('找不到備份:%s' % bak)
    n = os.path.getsize(bak)
    if n == 0:
        raise SystemExit(
            '備份是 0 bytes(多半是上次備份到一半被中斷),不敢拿它覆蓋 %s。' % dst)
    with open(bak, 'rb') as _f:
        head = _f.read(8)

    def _stop(why):
        raise SystemExit(
            '這份備份是壞的,不敢拿它覆蓋 %s。\n'
            '  %s\n'
            '  多半是備份途中被中斷(磁碟滿、外接碟拔掉、按了 Ctrl-C)。\n'
            '  請改用你自己另外留的那一份備份。' % (dst, why))

    # 第 2 道:BIGF 封裝檔。檔頭 +0x04 那 4 個位元組是「整個檔應該多長」,
    # 拿它跟實際長度對。截斷的備份這一關一定過不了。
    # 大小端都收:同一個欄位在不同檔裡兩種寫法都出現過(見上面的實測數字)。
    if len(head) == 8 and head[:4] == b'BIGF':
        le = struct.unpack('<I', head[4:8])[0]
        be = struct.unpack('>I', head[4:8])[0]
        if le != n and be != n:
            _stop('檔頭說它應該是 %d bytes(或 %d),實際只有 %d bytes。' % (le, be, n))
        return _do_copy(bak, dst)

    # 第 3 道:.LOC 語系檔。它沒有「總長度」欄位可以對,所以改成順著結構走一遍:
    # 檔頭指到的 LOCL 在不在該在的位置、位移表有沒有被切掉、
    # 最後一條字串的位移有沒有超出檔尾。截斷之後最後那一條一定會指到檔案外面。
    if len(head) >= 4 and head[:4] == b'LOCH':
        try:
            d = open(bak, 'rb').read()
            L = struct.unpack('<I', d[16:20])[0]
            if L + 16 > n or d[L:L + 4] != b'LOCL':
                _stop('語系檔的字串區(LOCL)應該在位移 %d,那裡不是 LOCL。' % L)
            lcnt = struct.unpack('<I', d[L + 12:L + 16])[0]
            if lcnt <= 0 or L + 16 + lcnt * 4 > n:
                _stop('語系檔的位移表被截斷了(宣告 %d 條)。' % lcnt)
            last = struct.unpack('<I', d[L + 16 + (lcnt - 1) * 4:L + 20 + (lcnt - 1) * 4])[0]
            if L + last >= n:
                _stop('語系檔最後一條字串在位移 %d,超出檔案結尾(%d bytes)。'
                      % (L + last, n))
        except SystemExit:
            raise
        except (struct.error, IndexError):
            _stop('讀不出語系檔的結構,它壞了。')

    # 第 4 道:Windows 執行檔。MZ 之後 +0x3C 是 PE 檔頭的位移,
    # PE 檔頭 +0x06 是節區數、+0x14 是選用檔頭長度,節區表接在後面、一個 40 bytes。
    # 每個節區記著它的內容在檔案裡從哪開始、有多長,最遠的那個不可以超出檔尾。
    if len(head) >= 2 and head[:2] == b'MZ':
        try:
            d = open(bak, 'rb').read()
            pe = struct.unpack('<I', d[0x3C:0x40])[0]
            if pe + 24 > n or d[pe:pe + 4] != b'PE\x00\x00':
                _stop('執行檔的 PE 檔頭不在它該在的地方,檔案不完整。')
            nsec = struct.unpack('<H', d[pe + 6:pe + 8])[0]
            optsz = struct.unpack('<H', d[pe + 20:pe + 22])[0]
            sec = pe + 24 + optsz
            end = 0
            for i in range(nsec):
                o = sec + i * 40
                if o + 40 > n:
                    _stop('執行檔的節區表被截斷了(宣告 %d 個節區)。' % nsec)
                raw_sz, raw_off = struct.unpack('<II', d[o + 16:o + 24])
                end = max(end, raw_off + raw_sz)
            if end > n:
                _stop('執行檔的節區指到 %d bytes,實際只有 %d bytes。' % (end, n))
        except SystemExit:
            raise
        except (struct.error, IndexError):
            _stop('讀不出執行檔的結構,它壞了。')

    # 通用地板 —— 非 BIGF 走到這裡
    if os.path.exists(dst):
        live = os.path.getsize(dst)
        if live > 0 and n * 2 < live:
            _stop('備份只有 %d bytes,而要被蓋掉的那個檔有 %d bytes ——'
                  '差太多了(不到一半)。' % (n, live))
    return _do_copy(bak, dst)


def _do_copy(bak, dst):
    """上面每一道把關都過了,這裡才真的把備份換回去 —— 而且是原子的。

    切成獨立函式是為了「只留一個出口」:每一條檢查路徑最後都得經過它,
    所以以後要加新的把關,只要確定加在 _do_copy 之前,就不可能漏掉哪一條。

    ⚠️ 2026-09-05 改掉的:這裡原本只有一行 shutil.copy2(bak, dst)。
       copy2 是**先把 dst 截成 0 bytes**再一段一段複製,所以複製途中磁碟滿、
       外接碟被拔掉、按了 Ctrl-C,玩家的遊戲檔就停在 0 bytes 或半截狀態 ——
       而前面那五道把關驗的是「備份好不好」,擋不住這一種。
       現在的順序是:同資料夾開唯一暫存檔 → 整份寫進去 → fsync →
       把正本的權限套上去 → 跟備份對 SHA-256 → 過了才 os.replace。
       任何一步失敗就把暫存檔刪掉,**正本維持原樣**,備份也還在。
    """
    bak, dst = os.fspath(bak), os.fspath(dst)
    _reject_symlink(dst, '要還原的 %s' % dst)
    fd, tmp = _new_temp(dst, 'restore')
    try:
        with os.fdopen(fd, 'wb') as fo:
            with open(bak, 'rb') as fi:
                shutil.copyfileobj(fi, fo)
            fo.flush()
            os.fsync(fo.fileno())
        if os.path.exists(dst):
            # 保留正本原本的權限(mkstemp 開出來的是 0600)。
            shutil.copymode(dst, tmp)
        want, got = _sha256(bak), _sha256(tmp)
        if want != got:
            raise DataError(
                '還原用的暫存檔跟備份對不起來,沒有換上去。\n'
                '     備份 %s\n     暫存 %s\n'
                '     磁碟可能滿了或有問題。%s 一個位元組都沒有動,備份也還在。'
                % (want, got, dst))
        _replace_and_register(tmp, dst, '已經還原成備份的內容',
                              '把它還原成備份的內容')     # 理由同 _atomic_write
    except BaseException:
        try:
            os.remove(tmp)
        except OSError:
            pass
        raise




# 備份檔名 = 原檔名直接接上這個字尾(fonts.big → fonts.big.fontbak)。
# 本站每一支腳本用不同字尾,兩支工具才不會搶同一份備份。
BACKUP = '.fontbak'
# 重建 BIGF 時,目錄之後、每一段資料之間都補 0x00 到 4 的倍數。
# 名字沒改 ⇒ 目錄長度不變 ⇒ 第一筆資料的位移一定算出同一個數字,
# 這是 write_big 敢照原順序整份重寫的前提。
ALIGN = 4


class DataError(Exception):
    """「你的檔案或參數有問題」這一類、使用者看得懂的停止原因。

    main() 只接這一種和 FileNotFoundError,把它印成一行中文、回 exit code 2
    就結束。

    ⚠️ 但「不丟 traceback 就停下來」的路不只這兩條:參數打錯(例如
    --swap 後面漏了值)時 argparse 丟的是 SystemExit,它不經過 main() 那兩個
    except,用法由 Python 自己印到 stderr、回 exit code 2,一樣沒有 traceback。
    第三條是**什麼參數都不給**:2026-09-05 起遊戲資料夾是選填的(--selftest
    不需要它),所以那種情況變成 main() 自己印用法、回 exit code 1
    (2026-09-05 實測;在那之前是 argparse 說「缺 path」、回 2)。
    第四條是 --restore 遇到壞備份:_restore_from_backup 丟的也是 SystemExit,
    訊息印到 stderr、exit code 1,同樣沒有 traceback(2026-09-05 實測)。

    第五條是 Ctrl-C:main() 有接 KeyboardInterrupt,照「還沒動 / 正在換 / 已換」
    三態印一行中文說檔案動了沒有、回 exit code 130。

    正本或備份是符號連結時丟的是 DataError(exit code 2),跟壞備份那條
    (SystemExit / exit code 1)不同,因為它是「你的資料夾長得不對」,
    不是「備份壞了」。

    真正會印出完整 traceback 的,是這幾種以外的例外:那是程式的錯,
    不該包裝成一句好看的中文。
    """
    pass


# ─────────────────────────────────────────────────────────
#  BIGF
# ─────────────────────────────────────────────────────────

def _blob_map(raw, items, what):
    """把 read_big 的 items 壓成 {名字: 內容}。
    ⚠️ 同名項目會讓 dict 靜默覆蓋(後者蓋前者),所以這裡明確擋下來 ——
    正常的 fonts.big 沒有同名項目;會出現同名,是有人先改過名(例如把
    _jp 全改成 _en 之後多出兩個 dflt_en.ffn)。那種檔不該再餵進 --swap/--dedupe。"""
    # items 的每一筆是 [名字, 位移, 長度],位移與長度都是相對於整份 raw 的。
    # 這裡把它切成真正的位元組,之後的換字型與合併都只認名字、不再碰位移。
    seen, out = {}, {}
    for n, o, sz in items:
        if n in seen:
            raise DataError(
                '%s 裡有兩個同名項目 "%s" —— 這個檔改過名(或壞了),'
                '本工具不能對它做這個動作,否則會靜默丟掉其中一份。' % (what, n))
        seen[n] = True
        out[n] = raw[o:o + sz]
    return out


def read_big(path):
    """讀 BIGF 封裝檔的目錄。回傳 (整份位元組, 目錄, 目錄結束的位移)。

    BIGF 的檔頭固定 16 bytes,而且**同一個檔頭裡兩種位元組序都有**:

        位移 0x00   4 bytes   'BIGF' 這四個字
        位移 0x04   4 bytes   整個檔多長      小端序(本站量到手邊 5 份
                                              fonts.big 都是小端;
                                              專案另外量過的 295 個 BIGF 檔
                                              裡有 7 個是大端,所以還原那邊
                                              兩種都收)
        位移 0x08   4 bytes   裡面有幾個項目  大端序
        位移 0x0C   4 bytes   目錄之後的位置  大端序,本函式沒有用到。
                                              ⚠️ 這一格**不是**第一筆資料的位移。
                                              本站在這台開發機上找得到的 1,885 個
                                              BIGF 檔(含歷史資料與各種模組備份)
                                              全掃過一遍:1,885 個都夾在「目錄
                                              結束」與「第一筆資料的位移」之間,
                                              但真的等於第一筆資料的位移只有
                                              133 個。多出來的那一小截是目錄後面
                                              的尾標或補零:1,306 個是 8 個位元組
                                              的 L234 尾標、545 個是 3 個 0x00、
                                              8 個是 2 個 0x00、26 個什麼都沒有。
                                              要知道某一筆資料在哪,請讀那一筆
                                              自己寫在目錄裡的位移。

    目錄接在檔頭後面,一筆一筆排,每一筆長度不一樣:

        4 bytes 大端序   這筆資料在檔案裡的位移
        4 bytes 大端序   這筆資料的長度
        N bytes          名字,以一個 0x00 結尾

    因為名字長度不固定,只能一筆一筆往前走,不能用「第 k 筆 = 16 + k × 固定長」跳。

    名字用 latin-1 解碼而不是 utf-8:latin-1 把 0 到 255 每個位元組都對到
    一個字元、絕不丟例外,而且 encode 回去保證拿回原本那幾個位元組。
    字型名本來就是 ASCII,這樣寫只是讓奇怪的檔也不會卡在這一行。

    回傳的第三個值是目錄結束的位移。本檔所有呼叫端都用 _ 接走沒有在用,
    留著是因為它就是「第一筆資料最早可以放在哪」的下界。
    """
    with open(path, 'rb') as f:
        raw = f.read()
    if raw[:4] != b'BIGF':
        raise DataError('%s 開頭不是 BIGF' % os.path.basename(path))
    # 項目數在 +0x08,大端序;目錄從第 16 個位元組開始。
    count = struct.unpack('>I', raw[8:12])[0]
    items, p = [], 16
    for _ in range(count):
        # 先確定還剩得下 8 bytes 才解。壞檔在這裡停,比讀出一堆亂數位移好。
        if p + 8 > len(raw):
            raise DataError('目錄讀到一半就沒了')
        off, size = struct.unpack('>II', raw[p:p + 8])
        p += 8
        # 名字讀到第一個 0x00 為止。找不到 0x00 時 .index 會丟 ValueError,
        # 那代表檔案壞了;總比把後面幾 MB 都當成一個名字好。
        e = raw.index(b'\x00', p)
        items.append([raw[p:e].decode('latin-1'), off, size])
        p = e + 1
    return raw, items, p


def write_big(path, items, blobs):
    """照原順序重建。目錄長度不變(名字沒改),所以第一筆資料的位置也不變。

    流程:算出目錄要多長 → 先用 0x00 把檔頭那一段空出來 → 每一段資料依序
    接在後面(段與段之間補到 4 的倍數)並記下它落在哪 → 最後把檔頭連目錄
    整塊蓋回一開始空出來的那一段。要等資料都放完才知道位移,所以檔頭最後寫。

    ⚠️ 「位置不變」指的是**這支自己重跑幾次都一樣**(名字沒改 ⇒ 目錄長度
       不變 ⇒ 算出同一個 first)。跟 EA 原本打包出來的位置不保證相同:
       本站量到手邊那份中文 fonts.big(1,279,340 bytes、12 個項目)目錄結束
       在 266、第一筆資料放在 272,而這支會算出 268。差幾個位元組沒有影響,
       因為每一筆的位移都明明白白寫在目錄裡,遊戲照目錄找,不是照固定間距找。
       (同一份檔的 +0x0C 寫的是 269,266 與 272 都不是。那一格本來就不是
       「第一筆資料的位移」,見 read_big 的說明。)

    ⚠️ 這是「整份重打包」,不是接在檔尾。目錄指得到的每一段都會原樣寫回去,
       但**目錄指不到的位元組會不見**。本站 2026-08-30 量了手邊 5 份
       fonts.big:中文那 3 份(各 1,279,340 bytes)目錄指不到的只有 103 bytes
       而且全部是 0x00(對齊補的);2009 台灣模組那 2 份英文的
       (各 420,650 bytes)目錄指不到 207,698 bytes,其中 101,872 個不是 0。
       對後者做整份重打包,那 101,872 bytes 會消失。備份就是為了這種事。

    ⚠️ 檔頭 +0x04 的總長度寫**小端序**,而項目數、+0x0C 那一格、每一筆的
       (位移, 長度) 全部是**大端序**。同一個檔頭兩種位元組序不是筆誤,
       是照原檔的寫法。

    ⚠️ 2026-09-05 起最後那一步不是直接寫檔:整份組好之後交給 _atomic_write,
       先寫同資料夾的唯一暫存檔、fsync、讀回來逐位元組對過,才 os.replace
       換上去。中途壞掉的話玩家的 fonts.big 維持原樣,不會變成半截檔。
    """
    # 目錄長度 = 16 bytes 檔頭 + 每筆(8 bytes 的位移與長度 + 名字 + 1 個結尾 0x00)
    dirlen = 16
    for nm, _, _ in items:
        dirlen += 8 + len(nm) + 1
    # 第一筆資料從目錄之後、往上湊到 4 的倍數的地方開始
    first = (dirlen + ALIGN - 1) // ALIGN * ALIGN
    # 先用 0x00 把檔頭那一段佔起來,資料接在它後面長出去
    out = bytearray(first)
    offs = []
    for nm, _, _ in items:
        b = blobs[nm]
        # 接之前先記下「這一段會落在哪」,那就是等一下要寫進目錄的位移
        offs.append(len(out))
        out += b
        while len(out) % ALIGN:
            out += b'\x00'
    head = bytearray()
    head += b'BIGF'
    head += struct.pack('<I', len(out))          # 這個檔用小端序(照原檔)
    head += struct.pack('>I', len(items))        # 項目數:大端序
    # +0x0C 那一格的語意是「目錄區結束之後的位置」,不是「第一筆資料的位移」
    # (為什麼不是,見 read_big 的說明)。這支寫進去的是對齊後的 first:
    # 它不小於這份檔的目錄結束,又剛好等於第一筆資料真正的位移,
    # 兩種讀法都對得上,所以照樣寫得對。
    head += struct.pack('>I', first)             # +0x0C:大端序
    for (nm, _, _), o in zip(items, offs):
        head += struct.pack('>II', o, len(blobs[nm]))
        head += nm.encode('latin-1') + b'\x00'
    # 名字沒改 ⇒ head 的長度必定等於 dirlen ⇒ 一定塞得進前面空出來的 first bytes。
    # 這一行是就地覆蓋,不是插入,所以整份的長度不會因為寫檔頭而改變。
    out[:len(head)] = head
    # 不是 open(path, 'wb') —— 那一行執行的瞬間玩家的 fonts.big 就被截成 0。
    # _atomic_write 先寫同資料夾的暫存檔、讀回來對過,才 os.replace 換上去。
    _atomic_write(path, bytes(out))
    return len(out)


def write_big_dedup(path, items, blobs):
    """跟 write_big 一樣重建,但內容相同的項目只寫一份,多個目錄項指到同一個位移。

    BIGF 的目錄是 (位移, 長度, 名字),沒有任何地方規定兩個項目不能指到同一段資料。
    所以內容一模一樣的字型可以共用,而每個名字都還在、每個字都還在。
    回傳 (新檔長度, 省下的位元組, 共用分組)。

    跟 write_big 唯一的差別在中間那個迴圈:多一張「這段內容已經寫在哪」的表。
    分組直接拿 bytes 當 key,內容一模一樣的自然落到同一組,不必比檔名、
    也不必自己算雜湊(而且 bytes 相等就是逐位元組相等,不會有碰撞問題)。

    ⚠️ 對遊戲來說這是完全合法的檔:目錄怎麼寫,遊戲就怎麼讀。
       但它跟原檔已經不是同一種佈局了,所以一樣要靠備份才回得去。

    ⚠️ 它跟 write_big 一樣是「整份重打包」(out = bytearray(first) 之後,
       只有目錄指得到的段落會被接回去),所以 write_big 那一條
       「目錄指不到的位元組會不見」在這裡一模一樣成立,不是只有「檔案變小」。
       本站 2026-09-03 在同樣那 5 份 fonts.big 上量:中文那 3 份
       (各 1,279,340 bytes)合併後 911,368,少掉 367,972 bytes,其中 367,884
       是合併省下來的,另外 88 bytes 是目錄指不到的 0x00 與對齊的差;
       2009 台灣模組那 2 份英文的(各 420,650 bytes)合併後 198,448,
       少掉 222,202 bytes,其中只有 14,525 是合併省下來的,
       其餘 207,677 bytes 是目錄指不到的那一段跟對齊補的 0x00。

    ⚠️ 跟 write_big 一樣,2026-09-05 起走 _atomic_write:先寫暫存檔、對過,
       才換上去。
    """
    dirlen = 16
    for nm, _, _ in items:
        dirlen += 8 + len(nm) + 1
    first = (dirlen + ALIGN - 1) // ALIGN * ALIGN
    out = bytearray(first)
    where = {}           # 內容 -> 已經寫在哪
    offs = []
    groups = {}          # 內容 -> [名字, ...]
    for nm, _, _ in items:
        b = blobs[nm]
        groups.setdefault(b, []).append(nm)
        # 這段內容寫過了:目錄照樣多一筆,位移指回上一次寫的地方,資料不再寫第二份。
        if b in where:
            offs.append(where[b])
            continue
        where[b] = len(out)
        offs.append(len(out))
        out += b
        while len(out) % ALIGN:
            out += b'\x00'
    head = bytearray()
    head += b'BIGF'
    head += struct.pack('<I', len(out))
    head += struct.pack('>I', len(items))
    head += struct.pack('>I', first)
    for (nm, _, _), o in zip(items, offs):
        head += struct.pack('>II', o, len(blobs[nm]))
        head += nm.encode('latin-1') + b'\x00'
    out[:len(head)] = head
    _atomic_write(path, bytes(out))      # 理由同 write_big
    # 只留「有兩個以上名字共用」的組;省下的量 = 每組的內容長度 × (名字數 - 1),
    # 因為每一組還是要留一份。
    shared = {k: v for k, v in groups.items() if len(v) > 1}
    saved = sum(len(k) * (len(v) - 1) for k, v in shared.items())
    return len(out), saved, shared


def size_field_endian(raw):
    """檔頭 +0x04 的總長度欄位,不同檔可能是大端或小端。回傳 '<' 或 '>'。

    判法是「哪一種讀出來剛好等於檔案實際長度」,兩種都對不上就回小端。
    這種「用量的、不要用猜的」在這裡特別重要:同一個欄位在本站量過的
    BIGF 檔裡兩種寫法都出現過。

    ⚠️ 2026-08-30 補說明時查到:**本檔目前沒有任何地方呼叫它**
       (write_big 直接寫死小端,因為手邊 5 份 fonts.big 量到的都是小端)。
       留著是因為要處理 fonts.big 以外的 BIGF 檔時會需要它。
    """
    for e in ('<', '>'):
        if struct.unpack(e + 'I', raw[4:8])[0] == len(raw):
            return e
    return '<'


# ─────────────────────────────────────────────────────────
#  QFS 只需要解壓來讀字元數
# ─────────────────────────────────────────────────────────

def qfs_size(blob):
    """不解壓,直接讀 QFS 檔頭宣告的解開後大小(3 bytes 大端序)。

    QFS(EA 自家的 RefPack)開頭兩個位元組固定是 0x10 0xFB;
    緊接著第 2、3、4 個位元組是「解開之後有多大」,大端序、只有 3 bytes
    (所以這個欄位表示得了的上限是 16,777,215 bytes)。
    開頭不是 0x10 0xFB 就當成沒有壓縮,直接回傳它本身的長度。

    整支腳本要的只是「這個字型解開後佔多少」,所以**完全不需要解壓**:
    少寫一整套解壓器,也就少一種把資料弄壞的可能。

    ⚠️ 這是「檔案自己宣告的數字」,不是解壓算出來的。宣告值跟真的解出來
       不一樣時(檔案壞了),這裡看不出來。
    """
    if blob[:2] != b'\x10\xfb':
        return len(blob)
    return (blob[2] << 16) | (blob[3] << 8) | blob[4]


# ─────────────────────────────────────────────────────────
#  指令
# ─────────────────────────────────────────────────────────

def paths(root, src=None):
    """算出兩條路徑:要被改的那個檔,以及拿來換過去的來源檔。

    要被改的永遠是 <遊戲資料夾>/data/fonts/fonts.big,沒有第二個選擇。

    來源檔:有 --source 就用它,不再自己找。沒有就照下面那張清單找第一個存在的。
    順序不是隨便排的:
      · 英文語系排在中文語系前面,因為英文版是「不會當」的那一邊,
        換過去才是在減少變數
      · fonts 與 Fonts 兩種大小寫都試。中文化模組是在 Windows(檔名不分
        大小寫)做出來的,搬到 Mac 或 Linux 上大小寫就會咬人

    全部找不到時**不丟例外**,回傳中文語系那條**大寫 Fonts** 的路徑,讓呼叫端自己
    決定要怎麼辦:cmd_show 沒有來源檔照樣能列現況,cmd_swap 才需要停下來。

    ⚠️ 那條回傳值是上面清單的**第 4 條**,不是最後一條。五條還是全部都試過了,
       只是全部落空時挑了中文語系大寫 Fonts 那條當代表,而清單最後試的那條
       是中文語系小寫 fonts。在區分大小寫的檔案系統(Linux,或是格式化成
       區分大小寫的 macOS 磁碟)上,這兩條是不同的路徑。
       本站在 macOS 上拿這一支 mvp_font_swap.py 本身量過:給一個不存在的
       遊戲資料夾,paths() 回傳的來源檔是 data/中文語系/Fonts/fonts.big。
    """
    live = os.path.join(root, 'data', 'fonts', 'fonts.big')
    if src:
        return live, src
    # 來源字型:優先找英文語系那份(那是「不會當」的基準),
    # 找不到才退回中文語系。兩個資料夾大小寫在不同系統上可能不同,都試。
    for parts in (('data', '2023英文語系', 'fonts', 'fonts.big'),
                  ('data', '英文語系', 'fonts', 'fonts.big'),
                  ('data', '2023英文語系', 'Fonts', 'fonts.big'),
                  ('data', '中文語系', 'Fonts', 'fonts.big'),
                  ('data', '中文語系', 'fonts', 'fonts.big')):
        p = os.path.join(root, *parts)
        if os.path.isfile(p):
            return live, p
    return live, os.path.join(root, 'data', '中文語系', 'Fonts', 'fonts.big')


def cmd_show(root, src=None):
    """不加任何旗標時跑的就是這個:只讀、只印,一個位元組都不寫。

    左邊一欄是「現在用的那份 fonts.big 裡,每個字型解開後多大」,
    右邊是「來源檔裡同名字型解開後多大」,最後標出換過去會多還是會少。
    找不到來源檔就只印左邊:沒得比,但至少看得到現況。

    兩邊都用**解開後**的大小來比,不是用檔案裡壓縮後的長度。
    壓縮後的長度會被壓縮率影響,而真正吃記憶體的是解開後那一份。
    """
    live, cn = paths(root, src)
    if not os.path.isfile(live):
        raise DataError('找不到 %s' % live)
    raw, items, _ = read_big(live)
    print('  現在用的  %s' % live)
    print('  大小      %s bytes' % '{:,}'.format(len(raw)))
    print()
    # 這個變數叫 cn 是早期留下來的名字,不代表它一定指到中文語系那一份:
    # paths() 自動找來源時英文語系排在中文語系前面,所以這裡拿到的
    # 通常是英文版。指定了 --source 就是指定的那一份。
    have_cn = os.path.isfile(cn)
    cnitems = {}
    if have_cn:
        craw, citems, _ = read_big(cn)
        cnitems = {n: craw[o:o + s] for n, o, s in citems}
        print('  來源字型  %s' % cn)
        print('  大小      %s bytes' % '{:,}'.format(len(craw)))
    else:
        print('  ⚠️ 找不到來源字型檔(%s)' % cn)
        print('     沒有它就沒得比,只能列出現況。')
    print()
    print('  %-16s %12s %12s %s' % ('字型', '現在(解開後)', '來源(解開後)', ''))
    print('  ' + '-' * 56)
    tot = 0
    for nm, off, size in items:
        # 照目錄記的位移與長度切出這一段,再問 QFS 檔頭它解開後有多大。
        blob = raw[off:off + size]
        a = qfs_size(blob)
        tot += a
        # 來源檔裡沒有同名字型就留白,不要猜、也不要拿別的字型湊。
        b = qfs_size(cnitems[nm]) if nm in cnitems else None
        mark = ''
        if b is not None and b > a:
            mark = '  ← 換過去會大 %s' % '{:,}'.format(b - a)
        elif b is not None and b < a:
            mark = '  ← 換過去會省 %s' % '{:,}'.format(a - b)
        print('  %-16s %12s %12s%s' % (nm, '{:,}'.format(a),
                                       '{:,}'.format(b) if b is not None else '—', mark))
    print('  ' + '-' * 56)
    print('  %-16s %12s' % ('解開後合計', '{:,}'.format(tot)))
    if cnitems:
        ct = sum(qfs_size(v) for v in cnitems.values())
        print('  %-16s %12s  (差 %s)' % ('來源合計', '{:,}'.format(ct),
                                          '{:+,}'.format(ct - tot)))


def cmd_swap(root, names, apply_it, src=None):
    """把指定的字型換成來源檔裡的版本。

    名字用「底線前面那一段」比對,而且不分大小寫:檔案裡叫 au20b_en.ffn,
    你只要打 au20b。這樣指令才能照著上面那張表打,不用去記字尾。
    打完整的名字(au20b_en.ffn)也認,那是同一個前綴命中兩筆時唯一問得清楚的講法。

    沒加 --apply 就只印預覽。加了才會:擋掉符號連結 → 備份(只在第一次)→
    整份重寫(先寫暫存檔、對過才 os.replace 換上去)→ 重讀複驗。
    順序不能換,備份一定要在寫之前。
    """
    live, cn = paths(root, src)
    if not os.path.isfile(cn):
        raise DataError('找不到來源字型檔:%s\n     用 --source 指定' % cn)
    raw, items, _ = read_big(live)
    craw, citems, _ = read_big(cn)
    # 兩份都攤成 {名字: 內容}。_blob_map 會擋掉同名項目(理由見它的說明)。
    # 注意這裡把參數 src 從「來源檔路徑」改指向「來源檔的內容表」,
    # 上面 paths() 已經用完它了。
    src = _blob_map(craw, citems, os.path.basename(cn))
    blobs = _blob_map(raw, items, os.path.basename(live))
    live_names = {n for n, _, _ in items}
    changed = []
    for want in names:
        # 底線前面那一段當作字型名(au20b_en.ffn → au20b),大小寫不計。
        # 一定要排序:live_names 是 set,不排序的話 hit[0] 取到哪一筆會跟著
        # Python 的字串雜湊隨機化跑,同一行指令跑兩次可能換掉不同的字型。
        # 這不是假想:剛安裝好的原版中文版那份 fonts.big(1,683,513 bytes /
        # 13 項)裡 dflt_en.ffn 與 dflt_jp.ffn 並存,前綴「dflt」就命中兩筆。
        # 2026-09-05 在暫存複本上拿它實測修之前的版本,同一行 --swap dflt:
        # PYTHONHASHSEED=2 與 8 印「dflt_en.ffn 兩邊本來就一樣,跳過」,
        # 另外六個 seed 印「來源檔裡沒有 dflt_jp.ffn」。
        # 二分測試靠的就是「同一步重跑結果一樣」,這種不確定性不能留。
        # 完整名字也收(n.lower() == want.lower()),否則下面那句「請把完整名字
        # 打出來」是句做不到的建議 —— 前綴比對認不出 twc14_jp.ffn。
        hit = sorted(n for n in live_names
                     if n.split('_')[0].lower() == want.lower()
                     or n.lower() == want.lower())
        if not hit:
            raise DataError('這個檔裡沒有叫 "%s" 的字型。可用的:%s'
                            % (want, ', '.join(sorted(n.split('_')[0] for n in live_names))))
        if len(hit) > 1:
            # 猜錯就是換掉玩家不想換的那個字型,而且它自己不會知道。
            raise DataError('"%s" 在這個檔裡命中 %d 個字型(%s)——'
                            '不敢替你猜是哪一個,請把完整名字打出來。'
                            % (want, len(hit), ', '.join(hit)))
        nm = hit[0]
        if nm not in src:
            raise DataError('來源檔裡沒有 %s' % nm)
        # 兩邊逐位元組相同就跳過。沒有效果的改動不值得動檔案,
        # 更不值得為它生出一份備份、把「最早那一份原檔」的位置佔掉。
        if blobs[nm] == src[nm]:
            print('  · %s 兩邊本來就一樣,跳過' % nm)
            continue
        before, after = qfs_size(blobs[nm]), qfs_size(src[nm])
        blobs[nm] = src[nm]
        changed.append((nm, before, after))
    if not changed:
        print('\n  沒有東西需要換。\n')
        return
    if not apply_it:
        print()
        for nm, b4, af in changed:
            print('  會換掉 %-16s 解開後 %s → %s  (%s)' %
                  (nm, '{:,}'.format(b4), '{:,}'.format(af), '{:+,}'.format(af - b4)))
        total = sum(qfs_size(v) for v in blobs.values())
        print()
        print('  換完之後解開後合計會是 %s bytes' % '{:,}'.format(total))
        print('     (全英文 777,520 能玩 · 全中文 2,726,912 當機)')
        print()
        print('  以上是預覽,還沒有動到任何檔案。')
        print('  確定要換的話,在剛才那一行最後面加上 --apply')
        return
    # 備份只在第一次做。第二次執行時 .fontbak 裡放的仍然是「最原始那一份」,
    # 而不是上一次改完的結果,所以 --restore 一定回得到原點。
    bak = live + BACKUP
    # 兩個都要擋:正本是符號連結的話,寫下去改到的是連結另一頭那個檔;
    # 備份的名字是符號連結的話,備份會落到別的地方,而且下一次 --restore
    # 會從那裡讀。擋在 _atomic_copy 之前,所以擋下來的時候什麼都還沒發生。
    _reject_symlink(live, live)
    _reject_symlink(bak, '備份檔 %s' % bak)
    # 上一行已經把「是符號連結」那一種擋掉了,所以這裡 exists 與 lexists 同義。
    if not os.path.exists(bak):
        _atomic_copy(live, bak)
        print('  已備份 → %s' % os.path.basename(bak))
    newlen = write_big(live, items, blobs)
    print()
    for nm, a, b in changed:
        print('  換掉 %-16s 解開後 %s → %s  (%s)' %
              (nm, '{:,}'.format(a), '{:,}'.format(b), '{:+,}'.format(b - a)))
    print()
    # ⭐ 二分測試要記的是「解開後合計」不是檔案大小:檔案大小被壓縮率影響,
    #    吃記憶體的是解開後那一份。兩個數字都印,是為了讓你看見它們會分家。
    total = sum(qfs_size(v) for v in blobs.values())
    print('  檔案 %s → %s bytes' % ('{:,}'.format(len(raw)), '{:,}'.format(newlen)))
    print('  ⭐ 解開後合計 %s bytes  ← 這個數字是二分測試要記的' % '{:,}'.format(total))
    print('     (全英文 777,520 能玩 · 全中文 2,726,912 當機)')
    # 複驗:把剛寫出去的檔重讀一次,項目數、每個名字的順序、每一段內容
    # 全部逐位元組對一遍。「寫完就當作成功」是這類工具最常見的謊,
    # 而這個檔案是玩家的遊戲資料,不能靠猜的。
    r2, i2, _ = read_big(live)
    ok = len(i2) == len(items)
    for (n1, _, _), (n2, o2, s2) in zip(items, i2):
        if n1 != n2 or r2[o2:o2 + s2] != blobs[n1]:
            ok = False
            break
    if not ok:
        # 複驗沒過就不能往下走:這個檔現在是壞的,不可以叫人拿它進遊戲,
        # 也不可以回 exit code 0 —— .bat / .command / 幫別人代跑的包裝
        # 都是看 exit code 判斷成敗的。
        raise DataError(
            '寫出去的 fonts.big 跟記憶體裡的對不上,這個檔現在是壞的。\n'
            '     不要拿它進遊戲。先還原回原點再回報:\n'
            '     python3 %s "%s" --restore'
            % (os.path.basename(__file__), root))
    print('  複驗:%d 個項目全部逐位元組相同 ✅' % len(i2))
    print()
    print('  進遊戲用那座會當的球場試一次。沒改善就還原:')
    print('    python3 %s "%s" --restore' % (os.path.basename(__file__), root))


def cmd_dedupe(root, apply_it):
    """把 fonts.big 裡內容相同的字型合併成共用一份。目錄裡的字型一個都不會少。

    做這件事的用意不是省硬碟,是做對照實驗:
    它讓「檔案變小」而「解開後的字型資料總量完全不變」。
    如果當機的上限卡在檔案大小,這樣就會好;卡在解開後的資料,就不會好。
    兩種結果都告訴我們答案在哪一層。

    ⚠️ 「檔案變小」不等於「只有重複的那幾份被拿掉」:它走的 write_big_dedup
       跟 write_big 一樣是整份重打包,目錄指不到的位元組會一起不見。
       本站 2026-09-03 量手邊那 2 份 2009 台灣模組英文版 fonts.big
       (各 420,650 bytes):合併後 198,448,少掉 222,202 bytes,
       其中只有 14,525 是合併省下來的。所以備份不是客套話。

    ⚠️ 2026-09-05 修掉的:**這條路在那之前一執行就 NameError。**
       下面第三行原本寫 paths(root, src),而 src 這個名字在這個函式裡不存在
       (只有 cmd_swap 有)。要講清楚的是它**不是「連檔案都還沒讀到」就停**:
       前一行的 read_big(live) 已經把整份 fonts.big 讀進記憶體、目錄也解完了。
       本站當時拿三份不同的 fonts.big 各實跑一次:剛安裝好的原版英文那份
       204,820 bytes / 12 個項目、2009 台灣模組英文那份 420,650 bytes /
       12 個項目、剛安裝好的中文版那份 1,683,513 bytes / 13 個項目,
       三次都停在 blobs = _blob_map(...) 那一行。不過從進函式到那一行只有讀
       沒有寫,備份與寫回都排在更後面,所以三次跑完那個 fonts.big 都跟跑
       之前逐位元組相同、也沒有留下 .fontbak:它確實沒有弄壞任何東西,
       只是做不到。修法是把那一行要的檔名直接用 live(paths() 回傳的第一個
       值就是它)。

    ⚠️ 同一輪補上的另一件事:**這條路的 --apply 以前沒有複驗。**
       cmd_swap 寫完會把檔案重讀一次、逐項逐位元組對一遍,再印「複驗:…」;
       這裡以前寫完只印「合併的組 / 檔案大小 / 解開後總量 / 字型數量」就結束,
       不重讀、不比對,對不上也不會叫你回報。現在兩條 --apply 的保證一樣了:
       合併之後好幾個目錄項會指到同一個位移,複驗順便證明那些位移都指對了。
    """
    live, _ = paths(root)
    raw, items, _ = read_big(live)
    # ⚠️ 2026-09-05 修掉的 NameError 就在這一行:原本寫的是
    #    os.path.basename(paths(root, src)[0]),而這個函式裡根本沒有 src
    #    這個名字(cmd_swap 才有)。要的只是「檔名拿來寫在錯誤訊息裡」,
    #    而 paths() 回傳的第一個值就是 live,直接用它。
    blobs = _blob_map(raw, items, os.path.basename(live))

    # 先看有沒有得省。沒有就不要動檔案。
    # 分組直接拿內容(bytes)當 key:一模一樣的自然落到同一組,
    # 不必比名字、也不必自己算雜湊,而且 bytes 相等就是逐位元組相等。
    groups = {}
    for nm, _, _ in items:
        groups.setdefault(blobs[nm], []).append(nm)
    shared = {k: v for k, v in groups.items() if len(v) > 1}
    if not shared:
        print('\n  這份 fonts.big 裡沒有內容重複的字型,沒有東西可以合併。\n')
        return

    if not apply_it:
        print()
        print('  可以合併的組:')
        for content, names in sorted(shared.items(), key=lambda kv: -len(kv[0])):
            print('    %-52s 各 %s bytes,留一份' %
                  (' = '.join(names), '{:,}'.format(len(content))))
        saved = sum(len(k) * (len(v) - 1) for k, v in shared.items())
        print()
        # ⚠️ 下面印出來的 saved 只算「合併省下來的」,不是檔案實際會少的量。
        #    write_big_dedup 是整份重打包,目錄指不到的位元組會一起不見:
        #    本站 2026-09-03 量 2009 台灣模組那份英文 fonts.big(420,650 bytes),
        #    這裡會印 14,525,而檔案實際少 222,202;中文那份(1,279,340 bytes)
        #    是印 367,884 而實際少 367,972。
        #    這句話原本只講 saved,所以下面把落差當場印出來,
        #    免得玩家拿預覽的數字去對合併後的檔案大小。
        print('  合併重複的字型會少 %s bytes,解開後總量完全不變,字型一個都不少。'
              % '{:,}'.format(saved))
        print('  ⚠️ 但這是整份重打包,目錄指不到的位元組會一起不見,')
        print('     所以檔案實際少掉的可能遠多於上面那個數字。動手前先備份。')
        print()
        print('  以上是預覽,還沒有動到任何檔案。')
        print('  確定要合併的話,在剛才那一行最後面加上 --apply')
        return

    # 跟 cmd_swap 同一個規矩:備份只做第一次,沿用的那一份才是最原始的檔。
    bak = live + BACKUP
    _reject_symlink(live, live)                      # 理由同 cmd_swap
    _reject_symlink(bak, '備份檔 %s' % bak)
    if not os.path.exists(bak):
        _atomic_copy(live, bak)
        print('\n  已備份原檔 → %s' % os.path.basename(bak))
    else:
        print('\n  已經有備份了,沿用 → %s' % os.path.basename(bak))

    before = len(raw)
    after, saved, shared = write_big_dedup(live, items, blobs)

    # 複驗(2026-09-05 補的,在這之前這條路寫完就結束):把剛換上去的檔重讀一次,
    # 項目數、每個名字的順序、每一段內容全部逐位元組對一遍。
    # 合併之後好幾個目錄項會指到同一個位移,這一關順便證明那些位移都指對了。
    r2, i2, _ = read_big(live)
    ok = len(i2) == len(items)
    if ok:
        for (n1, _o1, _s1), (n2, o2, s2) in zip(items, i2):
            if n1 != n2 or r2[o2:o2 + s2] != blobs[n1]:
                ok = False
                break
    if not ok:
        raise DataError(
            '合併之後寫出去的 fonts.big 跟記憶體裡的對不上,這個檔現在是壞的。\n'
            '     不要拿它進遊戲。先還原回原點再回報:\n'
            '     python3 %s "%s" --restore'
            % (os.path.basename(__file__), root))

    print('\n  合併的組:')
    for content, names in sorted(shared.items(), key=lambda kv: -len(kv[0])):
        print('    %-52s 各 %s bytes,留一份' %
              (' = '.join(names), '{:,}'.format(len(content))))
    # 三個數字一起印,才看得出這次實驗到底改了什麼:
    # 檔案變小、解開後總量完全沒變、字型數量一個沒少。
    # 注意「檔案大小」那一行印的是 before - after,也就是實際差額:
    # 裡面除了合併省下來的,還含目錄指不到的位元組(整份重打包會一起丟掉)
    # 跟對齊的差,所以它會大於上面預覽那一行印的 saved。
    print()
    print('  檔案大小   %s → %s  (少了 %s,%.1f%%)' %
          ('{:,}'.format(before), '{:,}'.format(after),
           '{:,}'.format(before - after), (before - after) * 100.0 / before))
    dec = sum(qfs_size(b) for b in blobs.values())
    print('  解開後總量 %s → %s  (完全沒變)' %
          ('{:,}'.format(dec), '{:,}'.format(dec)))
    print('  字型數量   %d → %d  (一個都沒少)' % (len(items), len(items)))
    print('  複驗:%d 個項目全部逐位元組相同 ✅' % len(i2))
    print()
    print('  還原:--restore\n')


def cmd_restore(root):
    """把 .fontbak 蓋回 fonts.big,比對過了才把備份檔刪掉。

    刪備份是刻意的:留著會讓下一次 --swap 誤以為「已經備份過了」而沿用一份
    已經沒有意義的檔。還原完等於回到出發點,下一次改會重新備份一次。
    但**要等比對過了才刪**:比對沒過就代表這次還原失敗,備份是唯一的退路,
    這種時候把它刪掉等於斷了自己的後路。

    順序是三步,不能換:
      1. _restore_from_backup —— 先驗備份再覆寫。0 bytes、BIGF 檔頭宣告長度
         跟實際長度對不上(截斷一定對不上)都會被擋下來,而且**擋下來的時候
         正本一個位元組都不會動**,備份也還留著。
         驗過之後真正的覆寫走 _do_copy,它是原子的:先寫同資料夾的唯一暫存檔、
         fsync、跟備份對 SHA-256,過了才 os.replace。所以覆寫途中被中斷,
         正本停在**舊的完整內容**,不會停在半截(2026-09-05 之前那一行是
         shutil.copy2,copy2 會先把正本截成 0)。
      2. 逐位元組比對 —— 兩邊整份讀出來比,不是比大小、也不是 zip 邊比邊停。
      3. 過了才刪備份、才印成功;比對沒過就留著備份、丟 DataError(exit code 2)。
         (第 1 步就被擋下來的那一種丟的是 SystemExit,訊息走 stderr、exit code 1。)

    ⚠️ 2026-09-05 之前這裡是直接把備份讀出來蓋回去、蓋完立刻刪備份的,
       沒有第 1 步也沒有第 2 步。實測拿截成八分之一的備份去 --restore:
       1,232,260 bytes 的正本被 159,917 bytes 蓋掉,畫面照樣印「✅ 已還原」、
       exit code 0,而且備份也一起沒了 —— 沒有第二次機會。修法是把
       本檔上面早就寫好、卻一個呼叫點都沒有的 _restore_from_backup 接上去。
    """
    live, _ = paths(root)
    bak = live + BACKUP
    # 先擋符號連結再問「在不在」:os.path.exists 對「指到不存在的檔的符號連結」
    # 回 False,只用它的話,那種備份會被說成「找不到備份」而不是被指出來。
    _reject_symlink(bak, '備份檔 %s' % bak)
    _reject_symlink(live, live)
    if not os.path.exists(bak):
        raise DataError('找不到備份 %s' % os.path.basename(bak))
    # 壞備份在這裡就會被擋下來(丟 SystemExit),下面兩行走不到,
    # 所以正本不會被蓋、備份也不會被刪。
    _restore_from_backup(bak, live)
    # 比完整長度,不用 zip:zip 會在短的那一邊停,備份被截斷時它反而看不出來。
    with open(bak, 'rb') as f:
        want = f.read()
    with open(live, 'rb') as f:
        got = f.read()
    if got != want:
        raise DataError(
            '還原之後 fonts.big 跟備份對不上(備份 %s bytes、現在 %s bytes)。\n'
            '     備份沒有刪,還留在 %s —— 請不要再動它,直接回報。'
            % ('{:,}'.format(len(want)), '{:,}'.format(len(got)),
               os.path.basename(bak)))
    os.remove(bak)
    print('\n  ✅ 已還原 fonts.big(%s bytes,與備份逐位元組相同),備份檔已移除。\n'
          % '{:,}'.format(len(got)))


def main():
    """把命令列參數接成一個動作,並且把例外翻成一行中文。

    判斷順序是 --restore → --dedupe → --swap → 什麼都沒加(只看現況)。
    一次只做一件事,所以同時加兩個旗標時,排在前面的贏。
    --apply 不是動作,是「--dedupe / --swap 要不要真的寫檔」的開關;
    單獨加 --apply 會走到「只看現況」那一條,什麼都不會被寫。
    ⚠️ 它管不到 --restore。--restore 排在最前面而且自己就會寫檔,
       不加 --apply 一樣會把 .fontbak 蓋回去、蓋完再把備份刪掉。

    exit code:0 成功、1 是什麼參數都沒給(印用法)、2 是檔案或參數有問題、
    130 是被 Ctrl-C 中斷。壞備份被 _restore_from_backup 擋下來是 1
    (它丟的是 SystemExit,走在 main 的 except 外面)。

    ⚠️ 130 那一條會**先看檔案到底動了沒有**再決定講什麼,而且看的是三態:
    _REPLACING(正在換)與 _WRITTEN(已換)兩張表都空的,才說「什麼都沒有動到」。
    _WRITTEN 只有 os.replace 真的做完才會有東西進去,不是「開始寫了」就記;
    _REPLACING 是「換名做到一半」的中間態,2026-09-06 補的 —— 在那之前,
    Ctrl-C 剛好落在 os.replace 與登記那一行中間的話,這裡會照舊狀態說
    「還沒有換過任何檔案」,而檔案其實已經換過了(實測:對變體檔跑真實的
    --swap --apply,exit code 130、印「跟動手前一模一樣」,正本 SHA-256 卻變了)。
    現在那兩行被 _NoInterrupt 綁成一段,中間態則是連 _NoInterrupt 都裝不上時的保險。
    說錯成沒動而其實動了,玩家就不會去還原。

    只接 DataError 與 FileNotFoundError 兩種例外,它們代表「你的檔案或參數
    有問題」,印一行中文、回 exit code 2。

    ⚠️ 不是「這兩種一行中文 / 其他全部 traceback」的二分法:參數打錯時 argparse
    丟的 SystemExit 走在這兩個 except 外面,用法由 Python 自己印到 stderr、
    回 exit code 2,也不會有 traceback。什麼參數都不給則是 main() 自己印用法、
    回 exit code 1(2026-09-05 起遊戲資料夾是選填的,因為 --selftest 不需要它;
    在那之前是 argparse 說「缺 path」、回 2)。本檔上面那個
    _restore_from_backup() 丟的也是 SystemExit,cmd_restore 從 2026-09-05 起
    會呼叫它,所以壞備份走的是這一條:訊息印到 stderr、exit code 1、
    沒有 traceback(實測)。

    這幾種以外的例外才會丟出完整 traceback:那是程式的錯,不該被包裝成一句
    好看的中文而讓人以為是自己弄錯。
    """
    # 這一段會原封不動印在 --help 最後面(要靠 RawDescriptionHelpFormatter,
    # 預設的格式器會把換行與縮排吃掉)。二分測試的做法寫在腳本自己的說明裡
    # 而不是只寫在網頁上,是為了讓「只拿到這支檔」的人也知道該怎麼用。
    EPILOG = """
二分測試的做法(每一步都可逆):

  1. 先看現況
     python3 mvp_font_swap.py "<遊戲資料夾>"

  2. 從「沒有中文的那幾個」開始換成來源檔裡的版本,看會不會當
     (沒指定 --source 時,自動找的順序是英文語系排在中文語系前面,
      所以找得到英文語系那一份時,這一步換過去的是英文版。
      本站在測試機那份中文化 fonts.big 上量到這三個都不含中文,
      換掉不會少任何一個中文字)

     先預覽(不會動到任何檔案):
     python3 mvp_font_swap.py "<遊戲資料夾>" --swap au20b,Tw24,dflt

     看過預覽沒問題,同一行最後面加上 --apply 才真的換:
     python3 mvp_font_swap.py "<遊戲資料夾>" --swap au20b,Tw24,dflt --apply

  3. 再把真正的那六個中文字型也換成來源檔裡的版本
     (來源是英文版的話,這一步會讓中文暫時消失。
      它是拿來確認兇手在不在這六個裡面,測完用第 4 步還原)

     先預覽:
     python3 mvp_font_swap.py "<遊戲資料夾>" --swap frk12,twc14,twc16,twc18,twcnb,twn14

     確定了再加 --apply:
     python3 mvp_font_swap.py "<遊戲資料夾>" --swap frk12,twc14,twc16,twc18,twcnb,twn14 --apply

  4. 還原
     python3 mvp_font_swap.py "<遊戲資料夾>" --restore

⚠️ 一次只加一組,加完就進遊戲測。一次全換就分不出是哪一個。
"""
    # Windows 繁體中文(cp950)把輸出導到檔案或管線時,Python 會改用地區編碼,
    # 而 ⭐ ✅ ❌ ⚠️ 這幾個字元 cp950 編不出來。實測(PYTHONIOENCODING=cp950):
    # --swap --apply 在**檔案已經寫完之後、印複驗之前**以 UnicodeEncodeError 收場;
    # --restore 是還原做完了才炸;連 --help 都印不完。訊息殘缺成 ? 可以接受,
    # 已經動過檔的流程崩在最後一行不行 —— 使用者會誤判成敗。
    # 這一段要排在 ArgumentParser 之前,--help 的內容才蓋得到。
    for _s in (sys.stdout, sys.stderr):
        try:
            _s.reconfigure(errors='replace')   # TextIOWrapper.reconfigure 是 3.7 起有的
        except Exception:
            # 被導到不是 TextIOWrapper 的東西時沒有這個方法。
            # 這裡是「錦上添花」的保護,失敗就照舊,不能反過來害腳本開不起來。
            pass
    ap = argparse.ArgumentParser(
        description='一個一個換字型,找出是哪一個把記憶體吃爆',
        formatter_class=argparse.RawDescriptionHelpFormatter, epilog=EPILOG)
    # nargs='?' 是為了 --selftest:它不需要遊戲資料夾。
    # 兩個都沒給就印用法、回 exit code 1(跟「檔案有問題」的 2 分開)。
    ap.add_argument('path', nargs='?', help='遊戲安裝資料夾(它的下一層才是 data)')
    ap.add_argument('--swap', metavar='名字,名字',
                    help='把這些字型換成來源檔裡的版本(逗號分隔,不用寫 _en.ffn。'
                         '沒指定 --source 時自動找,英文語系排在中文語系前面)')
    ap.add_argument('--restore', action='store_true', help='還原 fonts.big')
    ap.add_argument('--apply', action='store_true', help='真的寫入(沒加就只是預覽)')
    # ⚠️ 下面那句「只是檔案變小」講的是字型內容:合併之後每個名字、每個字都還在。
    #    但它不代表檔案只少掉重複的那幾份:write_big_dedup 是整份重打包,
    #    目錄指不到的位元組會一起不見,數字寫在 write_big_dedup 的說明裡。
    ap.add_argument('--dedupe', action='store_true',
                    help='內容相同的字型合併成共用一份(字型一個都不會少;但它是整份'
                         '重打包,目錄指不到的位元組會一起不見,檔案少掉的會多於合併'
                         '省下來的)')
    ap.add_argument('--source', metavar='檔案',
                    help='來源 fonts.big(不指定就自動找 2023英文語系 / 中文語系)')
    ap.add_argument('--selftest', action='store_true',
                    help='自我測試,不碰任何遊戲檔(自己造一份最小的 BIGF 來測)')
    args = ap.parse_args()
    if args.selftest:
        return selftest()
    if not args.path:
        ap.print_help()
        return 1
    try:
        if args.restore:
            cmd_restore(args.path)
        elif args.dedupe:
            cmd_dedupe(args.path, args.apply)
        elif args.swap:
            cmd_swap(args.path, [x.strip() for x in args.swap.split(',') if x.strip()],
                     args.apply, args.source)
        else:
            print()
            cmd_show(args.path, args.source)
            print()
        return 0
    except DataError as e:
        print('\n  停下來了:%s\n' % e)
        return 2
    except FileNotFoundError as e:
        print('\n  找不到檔案:%s\n' % e)
        return 2
    except PermissionError as e:
        # 資料夾唯讀(或檔案被別的程式鎖住)是「你的環境」的問題,不是程式的錯,
        # 所以印一行中文而不是丟 traceback。動了沒有一樣看 _WRITTEN,不用猜的。
        print('\n  沒有權限寫入:%s' % e)
        print('     那個資料夾或檔案是唯讀的,或者被別的程式鎖住了。')
        if _REPLACING:
            # 三態的中間那一態:停下來的時候正在換名,不知道換完了沒有,
            # 只能照實說,不可以說「一個位元組都沒有動」(見 _REPLACING)。
            for p, why in _REPLACING:
                print('     ⚠️ 停下來的時候正在%s,不確定換完了沒有:%s' % (why, p))
        if _WRITTEN:
            for p, why in _WRITTEN:
                print('     ⚠️ 但這個檔已經動過了:%s —— %s' % (p, why))
        elif not _REPLACING:
            print('     你的遊戲檔一個位元組都沒有動。')
        print()
        return 2
    except KeyboardInterrupt:
        # 按 Ctrl-C 之後最需要知道的一件事是「我的遊戲檔到底動了沒有」。
        # 猜不得:說錯成「什麼都沒動」而其實動了,玩家就不會去還原。
        # 所以看的是三態(見 _REPLACING 那一段):_REPLACING 有東西就是
        # 「正在換」,那種時候**不可以**說沒動到;兩張表都空才可以。
        print()
        if _REPLACING:
            # 正常情況這裡永遠是空的(換名與登記被 _NoInterrupt 綁在一起);
            # 它不是空的,代表連 _NoInterrupt 都沒裝上,那就不可以猜。
            print('  ⏹ 中斷了,而且中斷的時候**正在替換**下面這個檔:')
            for p, why in _REPLACING:
                print('     %s —— 正在%s' % (p, why))
            print('     換名本身是原子的,所以它要嘛還是舊的、要嘛已經是新的,')
            print('     不會是半截 —— 但這裡分不出是哪一種。請還原,或自己')
            print('     跟 %s 那份備份比對過再繼續:' % BACKUP)
            print('     python3 %s "%s" --restore'
                  % (os.path.basename(__file__), args.path))
            if _WRITTEN:
                print('     另外下面這些已經換過了:')
                for p, why in _WRITTEN:
                    print('     %s —— %s' % (p, why))
            print()
            return 130
        if _WRITTEN:
            print('  ⏹ 中斷了,但**檔案已經動過了**:')
            for p, why in _WRITTEN:
                print('     %s —— %s' % (p, why))
            if any(w == '換成新的內容' for _p, w in _WRITTEN):
                print('     要回到原點:')
                print('     python3 %s "%s" --restore'
                      % (os.path.basename(__file__), args.path))
            else:
                print('     還原本身已經做完了,檔案就在原點,不必再跑一次。')
                print('     (備份檔還在不在,看上面那個資料夾裡的 %s)' % BACKUP)
        else:
            print('  ⏹ 中斷了。還沒有換過任何檔案,你的遊戲檔跟動手前一模一樣。')
            print('     (寫到一半的暫存檔已經清掉;備份 .fontbak 如果生出來了會留著,')
            print('      它是完整的,下一次執行會沿用。)')
        print()
        return 130



def _fake_big(entries):
    """造一份最小的 BIGF 給 --selftest 用。entries = [(名字, 內容), ...]

    佈局跟 write_big 算出來的一模一樣(目錄長度 → 對齊到 4 的倍數 → 資料接在
    後面,段與段之間補 0x00),所以 read_big 讀得出來。
    自己造是刻意的:自我測試一個遊戲檔都不碰。
    """
    dirlen = 16
    for nm, _b in entries:
        dirlen += 8 + len(nm) + 1
    first = (dirlen + ALIGN - 1) // ALIGN * ALIGN
    out = bytearray(first)
    offs = []
    for _nm, b in entries:
        offs.append(len(out))
        out += b
        while len(out) % ALIGN:
            out += b'\x00'
    head = bytearray()
    head += b'BIGF'
    head += struct.pack('<I', len(out))
    head += struct.pack('>I', len(entries))
    head += struct.pack('>I', first)
    for (nm, b), o in zip(entries, offs):
        head += struct.pack('>II', o, len(b))
        head += nm.encode('latin-1') + b'\x00'
    out[:len(head)] = head
    return bytes(out)


def _junk(d):
    """那個資料夾裡有沒有本工具留下來的暫存檔。成功或失敗都不該留下任何一個。"""
    return sorted(n for n in os.listdir(d)
                  if '.part-' in n or '.new-' in n or '.restore-' in n)


def selftest():
    """--selftest:自己造一份最小的 BIGF 來測,完全不碰遊戲檔。

    正向(先證明「正常流程真的做得到承諾的事」;少了正向,反向餌可能只是
    「整支都壞了」才過的):
      · 造出來的 BIGF 讀得出來,名字與內容都對
      · --swap --apply 換得動、備份是原檔、複驗那一關真的跑過
      · --restore 之後逐位元組回到原本的樣子,備份檔被刪掉
      · --dedupe --apply 合併得動,字型數量與解開後總量都沒變
        (這條同時是 2026-09-05 修掉的那個 NameError 的回歸測試)
      · 每一次成功的操作之後,資料夾裡不可以留下任何暫存檔

    反向餌(故意做一件必須失敗的事,或故意佈一個陷阱看防線咬不咬得住):
      1. 事先在**舊版猜得到的暫存名字** <備份>.part 上放一個指向資料夾外面的
         符號連結 —— 跑完 --swap --apply,外面那個檔必須一個位元組都沒變
         (2026-09-05 改用 tempfile.mkstemp 之前,這裡會把外面那個檔截斷)
      2. 同樣的陷阱放在 <正本>.part 上,一樣不可以被寫到
      3. 正本本身是符號連結 → 必須停下來,而且連結指到的檔不可以被動
      4. 備份的名字是**指到不存在的檔**的符號連結(os.path.exists 會回 False)
         → 必須說它是符號連結,不可以說「找不到備份」
      5. 截成八分之一的備份 → --restore 必須擋下來,正本逐位元組不變、備份還在
      6. 還原做到一半 os.replace 丟例外 → 正本必須維持**舊的完整內容**,
         不可以是 0 bytes 或半截,而且不留暫存檔
      7. --swap --apply 寫到一半 os.replace 丟例外 → 同上
      8. --dedupe --apply 寫到一半 os.replace 丟例外 → 同上
      9. 同名項目的 BIGF → _blob_map 必須擋下來(不然會靜默丟掉其中一份)
     10. 前綴命中兩個字型 → 必須要求打完整名字,不可以替使用者猜
     11. --swap --apply 寫出去的內容跟記憶體裡不一樣 → 複驗要咬住,
         而且訊息裡要有還原指令
     12. --dedupe --apply 同上
     13. 資料夾唯讀 → 寫不進去要停,正本不可以被動、不可以留下暫存檔
         (用 root 跑的話 chmod 擋不住,那種情況會跳過這一道而不是假裝測過)
     14. 在 _NoInterrupt 區塊裡送一個真的 SIGINT → 區塊裡不可以被打斷,
         離開區塊之後才可以丟出 KeyboardInterrupt
     15. 真的跑一次 --swap --apply,SIGINT 在 os.replace 做完之後才打進來 →
         exit code 130、輸出要**承認檔案已經動過**並印還原指令,正本真的變了
     16. 同一條路,SIGINT 打在還沒開始換名的時候 → exit code 130、輸出說
         「還沒有換過任何檔案」、正本逐位元組不變、不留「正在換」的登記
         (14/15/16 在送不出 SIGINT 的環境會跳過,而且會印出來說跳過,
          不會默默當成通過;所以最後那一行的餌數是算出來的,不是寫死的)

    ⚠️ 這一段在 python3 -O 底下**直接拒絕跑**:-O 會把 assert 全部拿掉,
       而下面的檢查幾乎都是 assert,跑下去會一路印到「全部通過」——
       那是假的綠燈。(2026-09-06 補這一道之前實測:python3 -O 跑 --selftest
       印的跟一般模式一模一樣、exit code 也是 0。)

    餌會失效而沒有人發現(2026-08-29 本站踩過:防線壞了測試照樣全綠),
    所以上面每一道都做過陰性對照:把對應的那道防線改回舊寫法,--selftest
    必須變紅。2026-09-05 這一輪就是這樣抓到餌 7 原本下錯地方的
    (它直接呼叫 _atomic_write,所以把 write_big 改回直接寫檔時它照樣全綠)。
    """
    # ⚠️ python -O 會把整支程式裡的 assert **全部拿掉**,而下面的檢查
    #    幾乎都是 assert(ck() 就是包了一層的 assert)。拿掉之後這一段會
    #    一路跑到最後印「全部通過」—— 那個綠燈是假的,比沒有測試更糟。
    #    所以 -O 直接拒絕跑,不留這個假綠的窗口。
    if sys.flags.optimize:
        print('--selftest 不能在 python -O 下跑:-O 會把 assert 全部拿掉,測試會假綠')
        return 2
    import contextlib
    import io
    import shutil as _shutil

    n_checks = 0

    def ck(cond, why):
        nonlocal n_checks
        n_checks += 1
        assert cond, why

    quiet = lambda: contextlib.redirect_stdout(io.StringIO())

    root = tempfile.mkdtemp(prefix='mvp_font_swap_selftest-')
    try:
        fonts = os.path.join(root, 'data', 'fonts')
        srcdir = os.path.join(root, 'data', '2023英文語系', 'fonts')
        outside = os.path.join(root, '外面')
        for d in (fonts, srcdir, outside):
            os.makedirs(d)
        live = os.path.join(fonts, 'fonts.big')
        srcp = os.path.join(srcdir, 'fonts.big')
        bak = live + BACKUP

        # twc14 與 twc16 內容一模一樣 —— --dedupe 要合併的就是這兩個。
        # dflt_en 與 dflt_jp 並存 —— 前綴 "dflt" 會命中兩筆,那是第 9 道餌。
        live_entries = [('au20b_en.ffn', b'A' * 100),
                        ('dflt_en.ffn', b'B' * 60),
                        ('dflt_jp.ffn', b'D' * 44),
                        ('twc14_en.ffn', b'C' * 80),
                        ('twc16_en.ffn', b'C' * 80)]
        src_entries = [('au20b_en.ffn', b'a' * 20),
                       ('dflt_en.ffn', b'B' * 60),
                       ('dflt_jp.ffn', b'd' * 12),
                       ('twc14_en.ffn', b'c' * 24),
                       ('twc16_en.ffn', b'c' * 24)]
        origin = _fake_big(live_entries)
        open(live, 'wb').write(origin)
        open(srcp, 'wb').write(_fake_big(src_entries))

        # ── 正向:讀得出來 ──────────────────────────────────────
        raw, items, _ = read_big(live)
        ck([n for n, _o, _s in items] == [n for n, _b in live_entries],
           '造出來的 BIGF 名字讀不對 —— 測試本身壞了')
        blobs = _blob_map(raw, items, 'selftest')
        ck(blobs == dict(live_entries), '讀出來的內容跟放進去的不一樣')

        # ── 餌 1 / 餌 2:舊版猜得到的暫存名字上放符號連結 ────────
        out1 = os.path.join(outside, '別人的檔1')
        out2 = os.path.join(outside, '別人的檔2')
        open(out1, 'wb').write(b'X' * 500)
        open(out2, 'wb').write(b'Y' * 500)
        os.symlink(out1, bak + '.part')       # 舊版備份用的名字
        os.symlink(out2, live + '.part')      # 舊版寫檔用過的名字
        with quiet():
            cmd_swap(root, ['au20b'], True)
        ck(open(out1, 'rb').read() == b'X' * 500,
           '餌 1 沒咬到:<備份>.part 那個符號連結指到的檔被寫壞了')
        ck(open(out2, 'rb').read() == b'Y' * 500,
           '餌 2 沒咬到:<正本>.part 那個符號連結指到的檔被寫壞了')
        ck(os.path.islink(bak + '.part') and os.path.islink(live + '.part'),
           '那兩個符號連結不該被本工具動到')
        os.remove(bak + '.part')
        os.remove(live + '.part')

        # ── 正向:換得動、備份是原檔、暫存檔沒有留下 ────────────
        ck(open(bak, 'rb').read() == origin, '備份的內容應該是動手前那一份')
        raw2, items2, _ = read_big(live)
        b2 = _blob_map(raw2, items2, 'selftest')
        ck(b2['au20b_en.ffn'] == b'a' * 20, 'au20b 沒有被換過去')
        ck(b2['dflt_en.ffn'] == b'B' * 60, '沒點名的字型被動到了')
        ck(len(items2) == len(live_entries), '換完之後字型數量變了')
        ck(_junk(fonts) == [], '成功之後不該留下暫存檔:%s' % _junk(fonts))

        # ── 餌 7 / 餌 8:寫到一半 os.replace 炸掉,正本要維持舊的完整內容 ──
        # ⚠️ 這兩道一定要走**真正的那條指令**(cmd_swap / cmd_dedupe),
        #    不可以直接呼叫 _atomic_write:2026-09-05 第一版就是直接呼叫,
        #    結果把 write_big 改回 open(遊戲檔, 'wb') 之後測試照樣全綠 ——
        #    餌下錯了地方,量到的是自己。
        #    這裡的前提是「備份已經在了」,所以 cmd_swap 會跳過備份那一步、
        #    直接進到寫檔;不然炸的會是備份那一步,寫檔那一段根本沒跑到。
        before = open(live, 'rb').read()
        real_replace = os.replace

        def _boom(a, b):
            raise OSError(28, '磁碟滿了(測試用的假故障)')

        ck(os.path.exists(bak), '餌 7 佈置錯了:備份要先在,才會跳過備份那一步')
        for tag, act in (('餌 7 --swap --apply',
                          lambda: cmd_swap(root, ['twc14'], True)),
                         ('餌 8 --dedupe --apply',
                          lambda: cmd_dedupe(root, True))):
            os.replace = _boom
            try:
                with quiet():
                    act()
            except OSError:
                pass
            else:
                raise AssertionError('%s 沒咬到:os.replace 炸了卻沒有丟例外' % tag)
            finally:
                os.replace = real_replace
            n_checks += 1
            ck(open(live, 'rb').read() == before,
               '%s 沒咬到:寫到一半炸掉,正本竟然被動了' % tag)
            ck(_junk(fonts) == [], '%s:失敗之後不該留下暫存檔:%s' % (tag, _junk(fonts)))

        # ── 餌 11 / 餌 12:寫出去的內容跟記憶體裡不一樣 ─────────────
        # 這兩道證明「寫完重讀、逐項逐位元組對一遍」那一關真的在運作:
        # 把 write_big / write_big_dedup 暫時換成「故意寫錯一段」的版本,
        # 兩條 --apply 都必須停下來、而且訊息裡要有還原指令。
        # (沒有這兩道的話,把複驗整段拿掉,--selftest 照樣全綠 ——
        #  2026-09-05 的陰性對照就是這樣抓到的。)
        g = globals()
        for tag, key, act in (
                ('餌 11 --swap --apply', 'write_big',
                 lambda: cmd_swap(root, ['twc14'], True)),
                ('餌 12 --dedupe --apply', 'write_big_dedup',
                 lambda: cmd_dedupe(root, True))):
            real_fn = g[key]

            def _wrong(path, items, blobs, _real=real_fn):
                bad = dict(blobs)
                bad[items[0][0]] = b'Z' * 7      # 只弄壞一段,其餘照寫
                return _real(path, items, bad)

            g[key] = _wrong
            try:
                with quiet():
                    act()
            except DataError as e:
                ck('--restore' in str(e), '%s:停下來的訊息裡要有還原指令' % tag)
            else:
                raise AssertionError('%s 沒咬到:寫錯了竟然沒有被複驗抓到' % tag)
            finally:
                g[key] = real_fn
            n_checks += 1
            # 這兩道是「已經寫下去才發現不對」,所以正本現在是壞的 ——
            # 那正是真實情況下要 --restore 的理由。這裡直接把場景放回上一步。
            open(live, 'wb').write(before)
        ck(_junk(fonts) == [], '餌 11/12:不該留下暫存檔:%s' % _junk(fonts))

        # ── 餌 13:資料夾唯讀 → 正本不可以被動,也不可以留下暫存檔 ────
        # 用 root 跑的話 chmod 擋不住,所以先確認「這台機器上它真的擋得住」;
        # 擋不住就跳過,不要假裝測過。
        os.chmod(fonts, 0o500)
        try:
            can_write = True
            try:
                probe_fd, probe = tempfile.mkstemp(dir=fonts)
                os.close(probe_fd)
                os.remove(probe)
            except OSError:
                can_write = False
            if not can_write:
                # 走 main() 而不是直接呼叫 cmd_swap:要驗的除了「正本沒被動」,
                # 還有「exit code 不是 0」—— 印個 ❌ 卻回 0 會讓包裝腳本誤判成功。
                old_argv = sys.argv
                sys.argv = ['mvp_font_swap.py', root, '--swap', 'twc16', '--apply']
                try:
                    with quiet():
                        rc = main()
                finally:
                    sys.argv = old_argv
                ck(rc == 2, '餌 13 沒咬到:寫不進去卻回 exit code %r' % rc)
                ck(open(live, 'rb').read() == before,
                   '餌 13 沒咬到:唯讀資料夾裡正本竟然被動了')
                ck(_junk(fonts) == [], '餌 13:不該留下暫存檔:%s' % _junk(fonts))
        finally:
            os.chmod(fonts, 0o700)

        # ── 餌 5:截成八分之一的備份,--restore 必須擋下來 ────────
        full_bak = open(bak, 'rb').read()
        open(bak, 'wb').write(full_bak[:len(full_bak) // 8])
        try:
            with quiet():
                cmd_restore(root)
        except SystemExit:
            pass
        else:
            raise AssertionError('餌 5 沒咬到:截斷的備份竟然還原得下去')
        n_checks += 1
        ck(open(live, 'rb').read() == before, '餌 5:被擋下來時正本不可以被動')
        ck(os.path.exists(bak), '餌 5:被擋下來時備份不可以被刪掉')

        # ── 餌 6:還原到一半 os.replace 炸掉 ─────────────────────
        open(bak, 'wb').write(full_bak)
        os.replace = _boom
        try:
            with quiet():
                cmd_restore(root)
        except OSError:
            pass
        else:
            raise AssertionError('餌 6 沒咬到:os.replace 炸了卻沒有丟例外')
        finally:
            os.replace = real_replace
        n_checks += 1
        ck(open(live, 'rb').read() == before,
           '餌 6 沒咬到:還原到一半炸掉,正本竟然變成半截')
        ck(_junk(fonts) == [], '餌 6:失敗之後不該留下暫存檔:%s' % _junk(fonts))
        ck(os.path.exists(bak), '餌 6:失敗的還原不可以把備份刪掉')

        # ── 正向:完整的備份還原得回去,而且備份會被刪掉 ─────────
        with quiet():
            cmd_restore(root)
        ck(open(live, 'rb').read() == origin, '還原之後應該逐位元組回到原本的樣子')
        ck(not os.path.exists(bak), '還原成功之後備份檔應該被刪掉')
        ck(_junk(fonts) == [], '還原成功之後不該留下暫存檔:%s' % _junk(fonts))

        # ── 餌 4:備份的名字是指到不存在的檔的符號連結 ───────────
        os.symlink(os.path.join(outside, '根本沒有這個檔'), bak)
        ck(not os.path.exists(bak) and os.path.islink(bak),
           '餌 4 佈置錯了:它應該是一個指到不存在的檔的符號連結')
        try:
            with quiet():
                cmd_restore(root)
        except DataError as e:
            ck('符號連結' in str(e),
               '餌 4 沒咬到:它被當成「找不到備份」而不是符號連結')
        else:
            raise AssertionError('餌 4 沒咬到:符號連結的備份竟然走得下去')
        n_checks += 1
        os.remove(bak)

        # ── 餌 3:正本本身是符號連結 ─────────────────────────────
        real_live = os.path.join(outside, '真正的 fonts.big')
        _shutil.copyfile(live, real_live)
        os.remove(live)
        os.symlink(real_live, live)
        try:
            with quiet():
                cmd_swap(root, ['au20b'], True)
        except DataError:
            pass
        else:
            raise AssertionError('餌 3 沒咬到:正本是符號連結竟然照寫')
        n_checks += 1
        ck(open(real_live, 'rb').read() == origin,
           '餌 3 沒咬到:連結另一頭那個檔被寫到了')
        ck(not os.path.exists(bak), '餌 3:被擋下來的時候不該生出備份')
        os.remove(live)
        open(live, 'wb').write(origin)

        # ── 正向:--dedupe --apply(順便是那個 NameError 的回歸測試)──
        with quiet():
            cmd_dedupe(root, True)
        raw3, items3, _ = read_big(live)
        b3 = _blob_map(raw3, items3, 'selftest')
        ck(len(items3) == len(live_entries), '合併之後字型數量不可以變')
        ck(b3 == dict(live_entries), '合併之後每一段內容都要跟原本一樣')
        ck(sum(qfs_size(v) for v in b3.values())
           == sum(qfs_size(v) for _n, v in live_entries),
           '合併之後解開後總量不可以變')
        ck(len(raw3) < len(origin), '合併之後檔案應該變小')
        ck(_junk(fonts) == [], '合併成功之後不該留下暫存檔:%s' % _junk(fonts))
        with quiet():
            cmd_restore(root)
        ck(open(live, 'rb').read() == origin, '合併之後也要還原得回去')

        # ── 餌 9:同名項目一定要擋下來 ───────────────────────────
        dup = _fake_big([('x.ffn', b'1'), ('x.ffn', b'2')])
        draw, ditems, _ = read_big(io_write(root, 'dup.big', dup))
        try:
            _blob_map(draw, ditems, 'dup.big')
        except DataError:
            pass
        else:
            raise AssertionError('餌 9 沒咬到:同名項目竟然過了')
        n_checks += 1

        # ── 餌 10:前綴命中兩個一定要停 ──────────────────────────
        try:
            with quiet():
                cmd_swap(root, ['dflt'], False)
        except DataError as e:
            ck('命中' in str(e), '餌 10 咬到的不是「命中兩個」那一條')
        else:
            raise AssertionError('餌 10 沒咬到:前綴命中兩個竟然還替人猜')
        n_checks += 1
        ck(open(live, 'rb').read() == origin, '預覽不可以動到檔案')

        # ── 餌 14 / 15 / 16:Ctrl-C 不可以說謊 ───────────────────
        # 測的是 2026-09-06 補的那一段:換名與登記綁成一段不可中斷的區間
        # (_NoInterrupt),外加「還沒動 / 正在換 / 已換」三態登記當保險。
        # 陰性對照:把 _replace_and_register 改回「os.replace 完才登記、
        # 中間不擋訊號」,餌 15 就會變紅 —— 它會印「還沒有換過任何檔案」,
        # 而正本的 SHA-256 已經變了。
        baits = 13
        fire = None
        if hasattr(signal, 'raise_signal'):          # Python 3.8 起才有
            fire = lambda: signal.raise_signal(signal.SIGINT)
        elif os.name != 'nt':
            # Windows 的 os.kill 不接 SIGINT(它會直接終結行程),
            # 所以那條只在非 Windows 上走。
            fire = lambda: os.kill(os.getpid(), signal.SIGINT)
        if fire is not None:
            # 裝得回原本的處理器,才代表這個環境裝得上(非主執行緒裝不上)。
            cur = signal.getsignal(signal.SIGINT)
            try:
                if cur is None:
                    fire = None
                else:
                    signal.signal(signal.SIGINT, cur)
            except (ValueError, OSError):
                fire = None
        if fire is None:
            print('  (這台機器送不出 SIGINT,餌 14/15/16 跳過,不當成通過)')
        else:
            # ── 餌 14:區塊裡不可以被打斷,離開才可以丟 ──────────
            seq = []
            try:
                with _NoInterrupt():
                    fire()
                    seq.append('區塊裡沒有被打斷')
            except KeyboardInterrupt:
                seq.append('離開區塊之後才丟出來')
            ck(seq == ['區塊裡沒有被打斷', '離開區塊之後才丟出來'],
               '餌 14 沒咬到:_NoInterrupt 沒有把 Ctrl-C 延後到區塊結束(%r)' % seq)
            baits += 1

            # ── 餌 15:換名做完之後才被 Ctrl-C → 必須承認換過了 ──
            # 備份先放好,cmd_swap 才會跳過備份那一步,直接進到寫正本;
            # 不然 SIGINT 會打在備份那一次換名上,正本那一段根本沒跑到。
            def _run_apply():
                buf = io.StringIO()
                old_argv = sys.argv
                sys.argv = ['mvp_font_swap.py', root, '--swap', 'au20b', '--apply']
                try:
                    with contextlib.redirect_stdout(buf):
                        rc = main()
                finally:
                    sys.argv = old_argv
                return rc, buf.getvalue()

            def _reset():
                del _WRITTEN[:]
                del _REPLACING[:]
                open(live, 'wb').write(origin)
                open(bak, 'wb').write(origin)

            def _replace_then_sigint(a, b, _real=real_replace):
                _real(a, b)
                fire()

            _reset()
            sha_before = _sha256(live)
            os.replace = _replace_then_sigint
            try:
                rc, said = _run_apply()
            finally:
                os.replace = real_replace
            ck(rc == 130, '餌 15 沒咬到:Ctrl-C 之後 exit code 應該是 130,'
                          '結果是 %r' % rc)
            ck(_sha256(live) != sha_before,
               '餌 15 佈置錯了:正本應該真的被換過,不然量到的不是這一道')
            ck('已經動過' in said,
               '餌 15 沒咬到:檔案換過了卻沒有承認(%r)' % said[-200:])
            ck('--restore' in said, '餌 15 沒咬到:換過了卻沒有印還原指令')
            ck(_REPLACING == [], '餌 15:登記完成之後不該留下「正在換」')
            ck(_junk(fonts) == [], '餌 15:不該留下暫存檔:%s' % _junk(fonts))
            baits += 1

            # ── 餌 16:還沒換名就被 Ctrl-C → 必須說沒動到 ─────────
            # 打在 fsync 之後、換名之前:那時候暫存檔寫好了,正本還沒動。
            real_fsync = os.fsync

            def _fsync_then_sigint(fd, _real=real_fsync):
                _real(fd)
                fire()

            _reset()
            os.fsync = _fsync_then_sigint
            try:
                rc, said = _run_apply()
            finally:
                os.fsync = real_fsync
            ck(rc == 130, '餌 16 沒咬到:exit code 應該是 130,結果是 %r' % rc)
            ck(open(live, 'rb').read() == origin,
               '餌 16 沒咬到:還沒換名就被中斷,正本竟然被動了')
            ck('還沒有換過任何檔案' in said,
               '餌 16 沒咬到:什麼都沒動卻沒有這樣說(%r)' % said[-200:])
            ck(_REPLACING == [], '餌 16:沒換成就不可以留下「正在換」的登記')
            ck(_junk(fonts) == [], '餌 16:失敗之後不該留下暫存檔:%s' % _junk(fonts))
            baits += 1
            del _WRITTEN[:]
            del _REPLACING[:]

        print('自我測試:全部通過(%d 道檢查,涵蓋 %d 組反向餌)' % (n_checks, baits))
        return 0
    finally:
        _shutil.rmtree(root, ignore_errors=True)


def io_write(root, name, data):
    """--selftest 用的小工具:把一段位元組寫成檔案,回傳路徑。"""
    p = os.path.join(root, name)
    with open(p, 'wb') as f:
        f.write(data)
    return p


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


# ─────────────────────────────────────────────────────────
#  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.
# ─────────────────────────────────────────────────────────

下面這一支會改你的遊戲檔(加上 --fix 之後)。 本站的原則是:會動你檔案的東西,你要能在跑之前先讀完它。所以它也整支印在這裡。

展開 / 收合完整原始碼(1800 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

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

"""
mvp_fix_loc.py —— 把中文語系檔裡「會讓遊戲當掉的格式符」拿掉

    先看有幾條      python3 mvp_fix_loc.py "<遊戲資料夾>"
    預覽修法        python3 mvp_fix_loc.py "<遊戲資料夾>" --fix
    真的修          python3 mvp_fix_loc.py "<遊戲資料夾>" --fix --apply
    還原            python3 mvp_fix_loc.py "<遊戲資料夾>" --restore
    自我測試        python3 mvp_fix_loc.py --selftest

⚠️ 只改 data/FEENG.LOC 與 data/IGENG.LOC,一定先備份成 .locbak,一行還原。
⚠️ **中文字一個都不會動**,只拿掉多出來的格式符。

─────────────────────────────────────────────────────────
 這在解什麼
─────────────────────────────────────────────────────────
2026-08-28,toni 的交叉實驗把範圍縮到只剩兩個檔:

  · 球場檔清到 8.51 MB(逐位元組相同)→ 英文能玩、中文照樣當
    ⇒ **「球場超過 10MB 就當」這條傳了二十年的規矩是錯的**
  · 字型瘦身 689 KB → 還是當  ⇒ 不是字型吃爆記憶體
  · 剩下的變數只有 `FEENG.LOC` 與 `IGENG.LOC`

把兩份逐條比對之後找到這個:

  ┌──────────────────────────────────────────────────────┐
  │  同一個字串編號,中英文放的是**完全不同的東西**            │
  │                                                       │
  │  編號 23484   英「Right Field Camera」      不要參數     │
  │               中「   -  %s得分。%c」        要 2 個參數  │
  │                                                       │
  │  編號 23505   英「Argument Intensity」      不要參數     │
  │               中「 - %s盜%d%s壘成功。%c」    要 4 個參數  │
  └──────────────────────────────────────────────────────┘

C 語言的 `printf` 遇到 `%s` 卻沒有對應的參數,會把**堆疊上的隨機值當成指標**
去讀記憶體 —— 那就是當機。

全掃的結果:
    IGENG.LOC(比賽中)  25 條
    FEENG.LOC(選單)   143 條
    合計 **168 條**「程式不會傳參數、字串卻要參數」

而反方向(中文的參數比英文**少**)有 247 條 —— **那不會當**,只會少印東西。
**這個不對稱正是「當機」而不是「顯示怪怪的」的原因。**
⚠️ 那 247 條裡有 79 條是量法造成的:CFMT 的旗標裡含一個半形空白,
   英文那邊的 'Checking %3 for %8 Saves.' 會被多算成兩個格式符,中文那邊
   沒有這種命中,於是英文顯得比較多。把空白從旗標裡拿掉重跑同一對檔,
   反方向剩 168 條(2026-09-03 量的);要修的那 168 條則一條不差,
   完全不受影響。細節見 CFMT 那一段。

⭐ 最關鍵的一群:IG 那 25 條裡有 11 條擠在編號 23482–23741,
   而那一段的英文是「中間三壘攝影機 / 右外野攝影機 / 抗議判決 / 投打小遊戲 /
   可變好球帶 / 打者視野 / 犯規球頻率」—— **全部是進比賽時一定會讀的比賽設定**。
   這正好對上 toni 描述的症狀:**讀取條讀完、進比賽的那一瞬間跳出**。

⚠️ 本站**沒有**實機證明「這 168 條就是主因」。上面是量到的結構事實加上症狀吻合。
   這支腳本就是拿來驗它的 —— 修掉之後如果還是當,那這條也不對。

─────────────────────────────────────────────────────────
 修法:只拿掉多出來的格式符,不動中文
─────────────────────────────────────────────────────────
以編號 23484 為例:

    修之前   「   -  %s得分。%c」     要 2 個參數
    修之後   「   -  得分。」          要 0 個參數 ← 跟英文版一樣

字面上那句話會變得不完整(少了球員名字),但**那句話本來就不該出現在那裡** ——
遊戲在那個編號要的是「右外野攝影機」,根本不是播報句。
所以拿掉之後不會有人看到殘缺的句子,只會看到一段沒有意義但**不會當**的文字。

─────────────────────────────────────────────────────────
 輸入 / 輸出 / 安全網 / 做不到的事
─────────────────────────────────────────────────────────
輸入
  · 一個位置參數:遊戲安裝資料夾(它的下一層才是 data)。
    給的資料夾底下沒有 data 就直接說「底下沒有 data 資料夾」,
    不會拐個彎去講英文語系資料夾。
  · 會被改到的檔只有兩個:<遊戲資料夾>/data/IGENG.LOC 與 data/FEENG.LOC
  · 比對用的基準:data/2023英文語系/ 或 data/英文語系/ 底下同名的那兩個檔。
    **沒有英文語系資料夾就跑不了。** 沒有基準就沒辦法判斷哪些格式符是多的,
    這時候它會停下來把話講清楚,不會用猜的去改你的檔。

輸出
  · 不加旗標        印每個檔有幾條對不上、前 8 條長什麼樣,一個位元組都不寫
  · --fix           沒加 --apply 就只印預覽(每個檔列前 3 條的修前修後)
  · --fix --apply   備份 → 重寫那兩個 .LOC → 重讀複驗(字串條數、每一條編號、
                    每一條內容都要對得上)。**對不上就自動用備份還原**,
                    備份留著不刪,而且回非 0 的 exit code
  · --restore       把 .locbak 蓋回去(**會先驗備份完不完整**),蓋完再跟備份
                    **逐位元組比一次**,比過了才刪掉備份檔。
                    **不需要 --apply**,打了就直接寫

安全網
  · --fix 預設就是預覽。沒有 --apply 就一個位元組都不會落地。
  · ⚠️ --restore 不吃上面那一條,它根本不看 --apply。main() 的判斷順序是
    --restore → --fix,--apply 完全不參與 --restore 那一條路:只打 --restore
    就會把 .locbak 蓋回 IGENG.LOC / FEENG.LOC,蓋完還把備份刪掉,
    所以「動之前一定先備份」對它不成立,它是反過來把備份用掉。
    2026-09-03 在暫存複本上實測:拿本機 data/ 底下那兩個語系檔當備份
    (IGENG.LOC 60,023 bytes、FEENG.LOC 416,753 bytes),正本各多接 5,000 個
    0x00 冒充「已經改過」,只打 --restore(沒有 --apply)之後兩個正本都被蓋回
    60,023 / 416,753 bytes、與備份逐位元組相同,兩個 .locbak 也都不見了。
    (下面那一條的把關仍然有效:備份自己被截斷的話會先被擋下來。)
  · 備份成 IGENG.LOC.locbak / FEENG.LOC.locbak,做法是「先寫一個**名字帶亂數**的
    暫存檔(tempfile.mkstemp,跟備份放同一個資料夾)、fsync、讀回來逐位元組
    比對過,再用 os.replace 換名」,中途被中斷不會留下半截備份。
    ⚠️ 名字帶亂數是刻意的。以前叫 `<備份檔>.part`,那是**可以事先預測的名字**:
    有人先在那裡擺一個指向資料夾外面的符號連結,複製就會沿著它去覆寫外面
    那個檔。mkstemp 用 O_CREAT|O_EXCL,連結先佔著也建不起來。
  · 動筆之前會先看正本與備份**是不是符號連結**,是的話整支停下來、
    一個位元組都不寫。判斷用 os.path.islink 而不是 os.path.exists ——
    指向不存在目標的連結在 exists() 眼中是 False,而 open(..., 'wb')
    照樣會沿著它去建立、覆寫外面那個檔。
  · 備份已經存在就沿用、不覆蓋,所以重複執行不會把最早那一份原檔洗掉。
    ⚠️ 沿用之前會**先驗那份備份完不完整**,而且兩個語系檔的舊備份是在
    動筆之前一起驗完的。壞掉的備份等於沒有備份,驗不過就整支停下來,
    一個位元組都不寫(以前只看「檔案存不存在」,半截備份會被照樣沿用)。
  · --restore 走 _restore_from_backup,會順著 LOCH 的結構走一遍
    (LOCL 在不在該在的位置、位移表有沒有被切掉、最後一條字串的位移有沒有
    超出檔尾),截斷的備份會被擋下來,不會蓋掉正本。
  · --restore 是**原子**的:先把備份寫進同資料夾一個名字帶亂數的暫存檔,
    fsync、權限比照正本、讀回來跟備份比長度與 sha256 都過了,才用 os.replace
    換上正本。中途出任何事(磁碟滿、外接碟被拔掉、按 Ctrl-C)都只丟掉暫存檔,
    **正本原封不動**。以前這裡是一行 shutil.copy2(備份, 正本) ——
    copy2 會先把正本截成 0 bytes 再一路寫,前面那五道把關全部在驗
    「備份好不好」,沒有一道擋得住「複製到一半斷掉」。
    換上去之後再跟備份**逐位元組比一次**(先比長度再比內容),
    比過了才刪備份;沒過就把備份留著並回非 0 的 exit code。
  · --restore 兩個語系檔各走各的:其中一份備份壞掉不會把另一份一起帶走,
    好的那份照樣還原,最後才用非 0 的 exit code 收尾並說清楚哪個沒還原。
  · 讀檔時位移只要指到檔案外面、或字串走不到結尾標記,就當成半截檔停下來,
    不會把它讀成一串空字串再寫回去。
  · 重寫語系檔本身也是原子的:先寫同資料夾的亂數暫存檔,再 os.replace 換上。
    正本永遠不會停在半截狀態。
  · 寫完立刻重讀複驗;**對不上就自動拿備份還原**(備份留著不刪),
    並且回非 0 的 exit code。以前只印一個 ❌ 就照樣回 0 ——
    看輸出的人以為成功了,壞掉的檔卻留在遊戲資料夾裡。
  · 中途按 Ctrl-C:程式記著 os.replace 到底做過幾次,所以它會明確告訴你
    「遊戲檔有沒有被動到」,而不是含糊地說沒事。exit code 一律 130。
    以前是安靜地回 0。
    ⚠️ 2026-09-06 補上這一條的另一半。光是「記著」還不夠:換名做完之後,
    還要再跑一行才算登記過,Ctrl-C 剛好落在那兩行**中間**的話,收尾讀到的
    紀錄是空的 —— 它會說「什麼都沒有動到」,而磁碟上那個檔已經換過了。
    現在那兩行被包成**不可中斷的一段**:這段期間收到的 Ctrl-C 先記著,
    等登記做完才照常丟出來(exit code 還是 130,按了不會沒用,只是晚兩行)。
    另外加一個「正在換 X」的中間態當保險:萬一連那道也失效(例如不是在
    主執行緒,裝不上訊號處理常式),收尾會照實說「中斷時正在替換 X,
    請用 --restore 還原或跟備份比對一次」,**不會**說「沒動到」。
    在複本上實測過:把 Ctrl-C 精準塞進那個窗口,修好前印「一個位元組都
    沒有動到」而檔案的 SHA-256 已經變了;修好後印「已經被改過」並附還原指令。
  · 兩個語系檔是**依序**處理的,所以第二個檔中途出事(磁碟滿、外接碟被拔掉、
    按了 Ctrl-C)的時候第一個已經改好了。這時候會先逐檔印「已改 / 沒改」
    再印還原指令,判斷方式是**當場拿每個檔跟它自己的 .locbak 比一次**,
    不是靠記帳(記帳會漏:複驗沒過那條路會先改檔、再自動還原回去)。
  · `--selftest` 不需要遊戲資料夾:它自己造一份最小的語系檔,把上面每一道
    把關**正反各測一次**(反向的那些是「故意做一件必須失敗的事」)。
    ⚠️ 它在 `python3 -O` 底下會**拒跑**並回 exit code 2:-O 會把 assert
    整句拿掉,測試工具寧可拒跑,也不要印一行沒有根據的「全部通過」。

做不到的事
  · **不會翻譯,也不會補字。** 它只拿掉多出來的格式符,中文字一個都不動。
  · 只看「數量」不看「型別」:中文有 4 個、英文有 2 個,就把中文砍到剩前 2 個。
    至於留下來那 2 個是不是 %s 對 %s、%d 對 %d,這支不管,本站也沒有驗過。
  · 反方向完全不碰:中文的格式符比英文**少**的那 247 條原樣留著。
    少印東西不會當,而每動一條就多一分風險。
  · 不主動處理 %1 %2 %6 這種 EA 自己的替換符。但 CFMT 的旗標裡含一個半形空白,
    「%數字 + 空白 + 英文字母」會被連成一段當成格式符(見 CFMT 那一段的說明)。
  · 只改 data/ 底下那兩個 .LOC。字型檔、執行檔、球場檔一個都不動。
  · ⚠️ 本站**沒有**實機證明「這 168 條就是主因」(見上一段)。

MIT License · Copyright (c) 2026 toni · 無外部相依,Python 3.7 以上
"""

import argparse
import hashlib
import os
import re
import shutil
import signal
import struct
import sys
import tempfile

# ── 備份的原子性(2026-08-29 上線前稽核加)────────────────────────────
# 原本是直接 shutil.copy2(遊戲檔, .bak)。複製途中被中斷(磁碟滿、外接碟拔掉、
# Windows 上按 Ctrl-C)會留下一個**半截的 .bak**;下一次執行看到它「存在」
# 就印「備份已存在,保留最早那一份」繼續改遊戲檔,之後 --restore
# 會拿那個半截檔覆蓋掉正本。
#
# 實測(2026-08-29):把 2,665,562 bytes 的備份截成 300,000 bytes,
# 本站防護最嚴的那支還原指令三道把關全過、印「✓ 已從備份還原」、exit code 0,
# 2.66 MB 的遊戲檔當場被 300 KB 蓋掉。magic 只看開頭,看不出後面少了多少。

# 每做完一次 os.replace 就把目的檔記在這裡。只有這一份紀錄能回答
# 「被 Ctrl-C 打斷的時候,使用者的遊戲檔到底有沒有被動到」——
# 沒有它就只能含糊地說「應該沒事」,而那句話有一半的機率是假的。
_WRITTEN = []

# 「正在換 X」這個中間態(2026-09-06 加)。
#
# ⚠️ 為什麼一份 _WRITTEN 不夠:os.replace 做完之後,還要再跑一行
#    _WRITTEN.append(dst) 才算「登記過」。Ctrl-C 剛好落在這兩行中間的話,
#    收尾的 _interrupted() 讀到的 _WRITTEN 是空的 —— 它會照舊狀態說
#    「什麼都沒有動到」,而磁碟上那個檔其實已經換過了。**那句話是假的。**
#
# 所以現在有三態:
#    · 不在 _REPLACING 也不在 _WRITTEN → 還沒動
#    · 在 _REPLACING 而不在 _WRITTEN   → 正在換,不知道換完了沒有
#    · 在 _WRITTEN                     → 已換
# 換名之前先進 _REPLACING,換完並登記進 _WRITTEN 之後才退出來。
_REPLACING = []


class _NoInterrupt(object):
    """把「os.replace + 登記」包起來,這一段期間 Ctrl-C 先記著,離開再照常丟出。

    這樣 KeyboardInterrupt 的收尾看到的登記,一定跟磁碟上的狀態一致 ——
    要嘛「還沒換、也沒登記」,要嘛「換過了、也登記了」,不會卡在中間。

    ⚠️ 這**不是**「按了沒用」:訊號只是被延後到這一小段(兩行)結束,
       離開這個區塊之後照樣丟 KeyboardInterrupt,exit code 一樣是 130。
    ⚠️ signal.signal 只能在主執行緒裝。裝不上去(ValueError / OSError)就
       退回原本的行為 —— 不會比以前更糟,而且上面那個三態的登記仍然擋著。
    """

    def __enter__(self):
        self._pending = False
        self._old = None
        try:
            self._old = signal.signal(signal.SIGINT, self._remember)
        except (ValueError, OSError):   # 非主執行緒等情況:退回原本行為,不會更糟
            self._old = None
        return self

    def _remember(self, signum, frame):
        self._pending = True

    def __exit__(self, exc_type, exc, tb):
        if self._old is not None:
            signal.signal(signal.SIGINT, self._old)
        if self._pending and exc_type is None:
            raise KeyboardInterrupt
        return False


def _reject_symlink(path, what):
    """正本或備份是符號連結就停下來,一個位元組都不寫。

    ⚠️ **不可以用 os.path.exists() 判斷。** 指向不存在目標的符號連結
       (dangling symlink)在 exists() 眼中是 False,而 open(..., 'wb')
       與 shutil.copy2() 照樣會沿著它去建立、覆寫連結指到的那個檔 ——
       那個檔可能在遊戲資料夾外面。要看連結**本身**就得用
       os.path.islink / os.lstat / os.path.lexists。
       (os.path.islink 在 Windows 上也認得 reparse point。)

    ⚠️ **只看這個檔本身,不往上檢查每一層父資料夾。** 稽核建議連父資料夾
       一起擋,本檔沒有照做,理由是那會擋掉合法的用法:玩家的遊戲資料夾
       路徑本來就可能經過符號連結(macOS 的 /Volumes、Linux 的家目錄
       搬過家、Windows 的 junction 都很常見),而那條路徑是玩家自己給的。
       這裡防的是「別人事先在遊戲資料夾裡擺一個連結」,不是玩家自己的路徑。
    """
    if os.path.islink(os.fspath(path)):
        raise SystemExit(
            '%s 是一個符號連結(symlink),不是真正的檔案:\n'
            '  %s\n'
            '  沿著它寫下去會改到資料夾外面的檔案,所以停在這裡,'
            '一個位元組都沒有動。\n'
            '  請把它移走或換成真正的檔案,再跑一次。' % (what, os.fspath(path)))


def _new_tmp(dst, tag):
    """在 dst 所在的資料夾開一個**名字帶亂數**的暫存檔,回傳 (fd, 路徑)。

    · 同一個資料夾 → 等一下的 os.replace 一定在同一個檔案系統上,換名才是原子的
    · 名字不可預測 → 沒有人能事先在 `<目的檔>.part` 擺一個符號連結,
      讓我們沿著它去覆寫資料夾外面的檔。mkstemp 內部是 O_CREAT|O_EXCL,
      那個名字已經存在(連結也算)就直接換下一個亂數,不會沿用。
    """
    dst = os.fspath(dst)
    d = os.path.dirname(os.path.abspath(dst)) or '.'
    return tempfile.mkstemp(dir=d, prefix='.%s.%s-' % (os.path.basename(dst), tag))


def _atomic_replace(src, dst, tag):
    """把 src 的內容原子地換上 dst:要嘛還是原本那份,要嘛已經是完整的新內容。

    ⚠️ **為什麼不能 shutil.copy2(src, dst)**:copy2 是「先把 dst 截成 0 bytes,
       再一路寫過去」。中途按 Ctrl-C、磁碟滿、外接碟被拔掉,dst 就停在 0 或
       半截 —— 而 dst 是使用者的遊戲檔。2026-09-05 上線前資安稽核點名的
       就是這一條路:還原前面那五道把關全部在驗「備份好不好」,
       沒有一道擋得住「複製到一半斷掉」。

    順序:寫進同資料夾的亂數暫存檔 → flush → os.fsync(真的落到碟上)
          → 權限比照目的檔 → 讀回來跟來源比長度與 sha256 → os.replace。
    任何一步失敗都只丟掉暫存檔,dst 原封不動。

    ⚠️ 本站有些腳本用 pathlib.Path 存路徑,有些用字串,所以兩邊先 os.fspath。
       2026-08-29 第一版寫成 dst + '.part',在 Path 上直接 TypeError,
       等於所有備份都失敗 —— 而且「半截備份被擋下來」那個測試照樣是綠的,
       是陰性對照(先證明正常流程真的會產生備份)抓到的。
    """
    src, dst = os.fspath(src), os.fspath(dst)
    _reject_symlink(dst, '要寫入的檔')
    fd, tmp = _new_tmp(dst, tag)
    try:
        h = hashlib.sha256()
        n = 0
        with os.fdopen(fd, 'wb') as w, open(src, 'rb') as r:
            # 一次一 MB,不要整份讀進記憶體 —— 這個 helper 在本站是共用的,
            # 別的課拿它去搬 5 MB 的執行檔。
            for chunk in iter(lambda: r.read(1 << 20), b''):
                h.update(chunk)
                n += len(chunk)
                w.write(chunk)
            w.flush()
            os.fsync(w.fileno())      # 不 fsync 的話「寫完了」只是作業系統說的
        # 權限比照目的檔:還原之後不該把唯讀變成可寫,或反過來。
        # 目的檔還不存在(第一次備份)時就比照來源。
        shutil.copymode(dst if os.path.isfile(dst) else src, tmp)
        h2 = hashlib.sha256()
        n2 = 0
        with open(tmp, 'rb') as f:
            for chunk in iter(lambda: f.read(1 << 20), b''):
                h2.update(chunk)
                n2 += len(chunk)
        if (n2, h2.digest()) != (n, h.digest()):
            raise SystemExit(
                '寫出去的內容跟來源對不上(%d bytes vs %d bytes),'
                '已經把暫存檔丟掉。\n'
                '  %s 原封不動,沒有被改到。' % (n2, n, dst))
        # 換名 + 登記是不可分割的一段,理由見 _REPLACING 那一段的說明。
        _REPLACING.append(dst)
        try:
            with _NoInterrupt():
                os.replace(tmp, dst)  # os.replace 是原子的
                _WRITTEN.append(dst)
                tmp = None
        except OSError:
            # 換名是原子的:丟 OSError 就是**沒有**換成,狀態是明確的,
            # 所以可以放心把「正在換」撤掉。撤不掉的是下面那一種。
            _REPLACING.remove(dst)
            raise
        else:
            _REPLACING.remove(dst)
        # ⚠️ 其他的 BaseException(例如 signal.signal 裝不上去時漏進來的
        #    KeyboardInterrupt)刻意**不撤**「正在換」的登記:那時候真的不知道
        #    換名做完了沒有,收尾就該照實說「正在替換」,不可以說「沒動到」。
    finally:
        # 這裡不寫 except BaseException:finally 連 Ctrl-C(KeyboardInterrupt)
        # 與 SystemExit 都會走到,不會在使用者的資料夾裡留下暫存檔垃圾。
        if tmp is not None:
            try:
                os.remove(tmp)
            except OSError:
                pass
    return n


def _atomic_write_bytes(dst, data, tag):
    """跟 _atomic_replace 同一套規矩,只是來源是記憶體裡的位元組。

    重寫語系檔走的是這一條。以前是 open(path, 'wb') 直接寫正本 ——
    那一行的第一件事就是把使用者的遊戲檔截成 0 bytes。
    """
    dst = os.fspath(dst)
    _reject_symlink(dst, '要寫入的檔')
    fd, tmp = _new_tmp(dst, tag)
    try:
        with os.fdopen(fd, 'wb') as w:
            w.write(data)
            w.flush()
            os.fsync(w.fileno())
        if os.path.isfile(dst):
            shutil.copymode(dst, tmp)
        with open(tmp, 'rb') as f:
            back = f.read()
        if back != data:
            raise SystemExit(
                '寫出去的內容跟打算寫的不一樣,已經把暫存檔丟掉。\n'
                '  %s 原封不動,沒有被改到。' % dst)
        # 換名 + 登記包成不可中斷的一段,理由同 _atomic_replace。
        _REPLACING.append(dst)
        try:
            with _NoInterrupt():
                os.replace(tmp, dst)
                _WRITTEN.append(dst)
                tmp = None
        except OSError:
            _REPLACING.remove(dst)
            raise
        else:
            _REPLACING.remove(dst)
    finally:
        if tmp is not None:
            try:
                os.remove(tmp)
            except OSError:
                pass
    return len(data)


def _atomic_copy(src, dst):
    """備份要嘛完整、要嘛不存在 —— 中間狀態不會留在 dst 這個名字上。"""
    return _atomic_replace(src, dst, 'part')


def _restore_from_backup(bak, dst):
    """還原之前先擋掉明顯壞掉的備份。

    ⚠️ 這裡**不能**比對「備份與目標大小相同」—— 本站多數腳本是把資料接到
    檔尾來改檔(專案鐵律:封裝檔不可重新打包),改完之後正本本來就比備份大,
    那樣比會擋掉每一次合法的還原。

    ── 2026-08-30 補上三道(上線前資安稽核抓到的真漏洞)────────────────
    原本只有「不是 0 bytes」+「BIGF 檔頭宣告長度」兩道。**BIGF 以外全破。**
    實測拿「前 1/8 的半截備份」去還原,六支腳本把正本吃掉而且都印成功:
        mvp_fix_loc / mvp_menu_text   .LOC        416,753 →  52,094
        mvp_edit_speed / mvp_ratings
        / mvp_player                  attrib.dat  840,643 → 105,080
        mvp_modernize                 mvp2005.exe 5,443,584 → 680,448
                                      (它還印「複驗:內容與備份相同 ✅」)
    最後那個會讓遊戲**完全開不起來**,而站上每一課都寫著「隨時可以 --restore」。

    現在檢查五件事:
      1. 備份不是 0 bytes
      2. BIGF:檔頭第 4-8 個位元組宣告的總長度要等於實際長度
         (兩種位元組序都接受;哪些檔是大端、各有幾個,以 reference/bigf.html 量到的為準,這裡不寫會過期的數字)
      3. LOCH(語系檔):檔頭指到的 LOCL 要在檔內,而且最後一條字串的位移
         也要在檔內 —— 截斷之後那個位移一定會超出去
      4. MZ(執行檔):PE 節區表裡 raw offset + raw size 的最大值不得超過檔案長度
      5. **通用地板**:非 BIGF 的備份不得小於「要被蓋掉的那個檔」的一半。
         非 BIGF 的工具都是原地改(大小幾乎不變),所以這條很安全;
         BIGF 走 append 會越改越大,所以刻意**不套**這條,由第 2 道負責。

    本檔真正會走到的是第 1、3、5 道(語系檔是 LOCH 開頭)。
    BIGF 與 MZ 那兩段留著,是因為這個函式在本站是整段共用的。
    """
    # 本檔開頭已經 import 過 struct,這裡再寫一次不影響行為;
    # 好處是這一整個函式可以原封不動複製到別支腳本,不必記得補 import。
    import struct
    bak, dst = os.fspath(bak), os.fspath(dst)
    # 第 0 道(2026-09-05 加):兩邊都不可以是符號連結。這一道要排在最前面 ——
    # 下面那些檢查都是在讀,擋不住「沿著連結寫到資料夾外面」。
    _reject_symlink(bak, '備份檔')
    _reject_symlink(dst, '要還原的遊戲檔')
    if not os.path.exists(bak):
        raise SystemExit('找不到備份:%s' % bak)
    n = os.path.getsize(bak)
    if n == 0:
        raise SystemExit(
            '備份是 0 bytes(多半是上次備份到一半被中斷),不敢拿它覆蓋 %s。' % dst)
    with open(bak, 'rb') as _f:
        head = _f.read(8)

    def _stop(why):
        raise SystemExit(
            '這份備份是壞的,不敢拿它覆蓋 %s。\n'
            '  %s\n'
            '  多半是備份途中被中斷(磁碟滿、外接碟拔掉、按了 Ctrl-C)。\n'
            '  請改用你自己另外留的那一份備份。' % (dst, why))

    # 第 2 道:BIGF 封裝檔。檔頭 +0x04 那 4 個位元組是「整個檔應該多長」,
    # 拿它跟實際長度對。大小端都收,因為同一個欄位兩種寫法都出現過。
    # (本檔處理的是 .LOC,走不到這一段。)
    if len(head) == 8 and head[:4] == b'BIGF':
        le = struct.unpack('<I', head[4:8])[0]
        be = struct.unpack('>I', head[4:8])[0]
        if le != n and be != n:
            _stop('檔頭說它應該是 %d bytes(或 %d),實際只有 %d bytes。' % (le, be, n))
        return _do_copy(bak, dst)

    # 第 3 道:.LOC 語系檔,也就是本檔真正會走的那一條。
    # .LOC 沒有「總長度」欄位可以對,所以改成順著結構走一遍:
    # 位移 0x10 那 4 bytes(小端序)說文字段 LOCL 在哪 → 那裡要真的是 'LOCL'
    #   → LOCL + 0x0C 是字串條數 → LOCL + 0x10 起是每條 4 bytes 的位移表
    #   → 最後一條的位移不可以指到檔案外面。
    # 檔案被截斷時,最後那一條一定會超出去,所以這一關擋得住半截備份。
    if len(head) >= 4 and head[:4] == b'LOCH':
        try:
            d = open(bak, 'rb').read()
            L = struct.unpack('<I', d[16:20])[0]
            if L + 16 > n or d[L:L + 4] != b'LOCL':
                _stop('語系檔的字串區(LOCL)應該在位移 %d,那裡不是 LOCL。' % L)
            lcnt = struct.unpack('<I', d[L + 12:L + 16])[0]
            if lcnt <= 0 or L + 16 + lcnt * 4 > n:
                _stop('語系檔的位移表被截斷了(宣告 %d 條)。' % lcnt)
            last = struct.unpack('<I', d[L + 16 + (lcnt - 1) * 4:L + 20 + (lcnt - 1) * 4])[0]
            if L + last >= n:
                _stop('語系檔最後一條字串在位移 %d,超出檔案結尾(%d bytes)。'
                      % (L + last, n))
        except SystemExit:
            raise
        except (struct.error, IndexError):
            _stop('讀不出語系檔的結構,它壞了。')

    # 第 4 道:Windows 執行檔。MZ 之後 +0x3C 是 PE 檔頭的位移,
    # PE 檔頭 +0x06 是節區數、+0x14 是選用檔頭長度,節區表接在後面、一個 40 bytes。
    # 每個節區記著內容從檔案哪裡開始、有多長,最遠的那個不可以超出檔尾。
    # (本檔處理的是 .LOC,走不到這一段。)
    if len(head) >= 2 and head[:2] == b'MZ':
        try:
            d = open(bak, 'rb').read()
            pe = struct.unpack('<I', d[0x3C:0x40])[0]
            if pe + 24 > n or d[pe:pe + 4] != b'PE\x00\x00':
                _stop('執行檔的 PE 檔頭不在它該在的地方,檔案不完整。')
            nsec = struct.unpack('<H', d[pe + 6:pe + 8])[0]
            optsz = struct.unpack('<H', d[pe + 20:pe + 22])[0]
            sec = pe + 24 + optsz
            end = 0
            for i in range(nsec):
                o = sec + i * 40
                if o + 40 > n:
                    _stop('執行檔的節區表被截斷了(宣告 %d 個節區)。' % nsec)
                raw_sz, raw_off = struct.unpack('<II', d[o + 16:o + 24])
                end = max(end, raw_off + raw_sz)
            if end > n:
                _stop('執行檔的節區指到 %d bytes,實際只有 %d bytes。' % (end, n))
        except SystemExit:
            raise
        except (struct.error, IndexError):
            _stop('讀不出執行檔的結構,它壞了。')

    # 通用地板 —— 非 BIGF 走到這裡
    # 語系檔是原地改(拿掉幾個格式符,檔案只會小一點點),
    # 所以「備份不到正本的一半」一定是備份出了事,不是正常的改動。
    if os.path.exists(dst):
        live = os.path.getsize(dst)
        if live > 0 and n * 2 < live:
            _stop('備份只有 %d bytes,而要被蓋掉的那個檔有 %d bytes ——'
                  '差太多了(不到一半)。' % (n, live))
    return _do_copy(bak, dst)


def _check_reusable_backup(bak):
    """沿用一份已經存在的 .locbak 之前,先驗它完不完整。備份不存在就什麼都不做。

    ⚠️ 為什麼要有這一關:--fix --apply 以前只看「.locbak 存不存在」就決定沿用,
       完全不驗它。而把關全部集中在 --restore 那一邊 —— 也就是**事後**。
       2026-09-05 在暫存複本上實測:先放兩份 head -c 5000 截出來的假 .locbak,
       再跑 --fix --apply,腳本印「修了 25 條 / 143 條」「備份 → IGENG.LOC.locbak」
       「複驗:… ✅」、exit code 0,兩個正本都被改寫;等到要救的時候 --restore
       才說「這份備份是壞的」exit 1 —— 那時候正本已經回不去了。
       壞掉的備份等於沒有備份,所以要在**動筆之前**就擋。

    判準跟 _restore_from_backup 的第 1、3 道同一條:不是 0 bytes,而且
    LOCH → LOCI → LOCL → 位移表 → 每一條字串都走得完(parse 現在自己會擋半截檔)。
    """
    _reject_symlink(bak, '備份檔')
    if not os.path.exists(bak):
        return
    why = None
    if os.path.getsize(bak) == 0:
        why = '它是 0 bytes(多半是上次備份到一半被中斷)。'
    else:
        try:
            parse(bak)
        except DataError as e:
            why = '%s' % e
    if why:
        raise DataError(
            '%s 已經存在,但它不是一份完整的語系檔。\n'
            '     %s\n'
            '     沿用壞掉的備份等於沒有備份 —— 改完之後 --restore 會拒絕它,\n'
            '     那時候正本就回不去了。所以停在這裡,一個位元組都沒有改。\n'
            '     請把它移走或改名(或改用你自己另外留的那一份),再跑一次。'
            % (os.path.basename(bak), why))


def _verify_restored(bak, dst):
    """還原之後、刪掉備份之前,拿還原出來的檔跟備份逐位元組比一次。

    ⚠️ 不可以用 zip(a, b) 逐位元組比 —— zip 在短的那一邊就停,兩個長度不同、
       前面又一樣的檔會被判成「相同」。所以這裡先比長度,再整份比。
    ⚠️ 比對沒過就**不刪備份**:那是使用者唯一的副本。
    """
    nb, nd = os.path.getsize(bak), os.path.getsize(dst)
    if nb != nd:
        raise SystemExit(
            '還原之後大小對不上(備份 %d bytes、還原出來的 %d bytes)。\n'
            '備份保留著沒有刪,請自己把 %s 複製成 %s。'
            % (nb, nd, os.path.basename(bak), os.path.basename(dst)))
    with open(bak, 'rb') as f1, open(dst, 'rb') as f2:
        if f1.read() != f2.read():
            raise SystemExit(
                '還原之後內容跟備份不一樣(長度相同但位元組不同)。\n'
                '備份保留著沒有刪,請自己把 %s 複製成 %s。'
                % (os.path.basename(bak), os.path.basename(dst)))


def _do_copy(bak, dst):
    """上面每一道把關都過了,真正的覆寫只有這裡。

    切成獨立函式是為了「只留一個出口」:每一條檢查路徑最後都得經過它,
    所以以後要加新的把關,只要確定加在 _do_copy 之前,就不可能漏掉哪一條。

    ⚠️ 2026-09-05 改成原子的。以前這裡是一行 shutil.copy2(bak, dst),
       而 copy2 的第一件事是**把 dst 截成 0 bytes**。前面那五道把關驗的
       全部是「備份好不好」,一道都擋不住「複製到一半斷掉」——
       斷在那裡,使用者的遊戲檔就停在 0 或半截。
    """
    return _atomic_replace(bak, dst, 'restore')




# 備份檔名 = 原檔名直接接上這個字尾(IGENG.LOC → IGENG.LOC.locbak)。
# 兩個語系檔各自有各自的備份,互不影響,所以可以只還原其中一個。
BACKUP = '.locbak'
# 真正會從堆疊取參數的格式符。%1 %2 %NX 這種是 EA 自己的替換,由遊戲的字串引擎
# 處理,不會直接餵給 printf,所以不碰。
# 拆開看這條式子:% 之後可以先有旗標與寬度精度(- + 空格 # 數字 小數點),
# 最後一個字元一定要是 printf 認得的轉換字元(d i u f F e E g G x X o s c p S C)。
# ⚠️ 旗標那一類裡**含一個半形空白**(還有小數點),所以「%數字 + 空白 +
# 一個剛好是轉換字元的英文字母」會被連成一段吃掉:'%1 in %2' 命中 '%1 i'。
# (這裡以前寫「EA 的 %2 %6 不會中,整支腳本碰不到它們」,那句話是錯的。)
# 2026-09-03 分別在兩份檔上量:
#   · 剛安裝好的英文原版 data/:FEENG.LOC 354 個命中裡 90 個含空白,
#     IGENG.LOC 29 個裡 9 個。例如 'Checking %3 for %8 Saves.' 中 '%3 f'
#     與 '%8 S',以及 '... on %3.  Continue?' 中 '%3.  C'。analyse 的 ne 是
#     拿英文那一條算的,所以英文那邊會被多算,方向是**少報**,不是多改。
#   · 手邊那份中文語系檔(FEENG 6,436 條 / IGENG 1,663 條):含空白的命中 0 個。
#     FEENG 只有 7 個 %數字 開頭的命中,全是合法的 %02d / %4d;IGENG 一個都沒有。
#     原本說的「FEENG 有 5 條含 %%、這條式子一個都沒命中」仍然成立。
#   · 把旗標裡的空白拿掉重跑同一對檔:要修的 168 條(FEENG 143 / IGENG 25)
#     一條不差,keep 一樣,strip_fmt 輸出逐字相同。所以這是說明寫錯,
#     不是這對檔上的行為錯了。差別只出現在反方向那 247 條(見檔頭):
#     拿掉空白後剩 168 條,其中 79 條是英文被多算才變成「少」的。
CFMT = re.compile(r'%[-+ #0-9.]*[diufFeEgGxXoscpSC]')


class DataError(Exception):
    """「你的檔案或參數有問題」這一類、使用者看得懂的停止原因。

    main() 只接這一種,把它印成一行中文、回 exit code 2 就結束。

    ⚠️ 但「不丟 traceback 就停下來」的路不只這一條。--restore 走的
    _restore_from_backup() 擋下壞掉的備份時丟的是 SystemExit,那段中文由
    Python 自己印到 stderr、回 exit code 1;參數打錯時 argparse 丟的也是
    SystemExit(印用法、回 exit code 2)。兩種都不會有 traceback,所以這裡
    不是「DataError 一行中文 / 其他全部 traceback」的二分法。

    真正會印出完整 traceback 的,是 DataError 與 SystemExit 以外的例外:
    那是程式的錯,不該包裝成一句好看的中文。
    """
    pass


def parse(path):
    """讀 .LOC 語系檔。回傳 (整份位元組, 一包解出來的欄位)。

    .LOC 是三段接在一起,**每個數字欄位都是小端序**:

        LOCH  檔頭      +0x04  這個檔頭多長。照它往後跳就是下一段的開頭
        LOCI  索引段    +0x04  文字段大概在哪(idxoff)
                        +0x08  總共有幾條字串(count)
                        +0x10  起,每 4 bytes 一筆。前 2 bytes 是**字串編號**
                               (遊戲程式拿這個號碼跟語系檔要字串),
                               後 2 bytes 本函式沒有用到
        LOCL  文字段    +0x10  起,每 4 bytes 一筆位移。這個位移是**相對於
                               'LOCL' 那四個字的開頭**,不是相對於檔案開頭
                               位移指過去就是字串本體:UTF-16LE,兩個 0x00 結尾,
                               長度沒有另外記在別的地方

    ⚠️ 文字段是用 b.find(b'LOCL', idxoff) 找的,不是照 idxoff 直接跳。
       也就是說 idxoff 在這裡只被當成「從這裡開始往後找」的起點,
       中間就算多幾個位元組也還是找得到。

    ⚠️ 解碼用 errors='replace'。讀進來只是為了數格式符,一條壞字元不該讓
       整支停掉。但 rebuild 是把**每一條**都重新編碼寫回去的,所以萬一原檔
       真有解不出來的位元組,它會被換成 U+FFFD 寫回去,而 cmd_fix 的複驗
       比的是「解出來的字串」,比不出這種差別。
       2026-08-30 拿手邊的中文語系檔實測:IGENG.LOC 1,663 條、FEENG.LOC
       6,436 條,U+FFFD 是 0 個,而且每一條重新編碼回去都跟原本的位元組
       逐位元組相同(8,099 條零例外)。手上那兩個檔沒有這個問題,
       但這不代表所有中文化版本都沒有。
    """
    with open(path, 'rb') as f:
        b = f.read()
    if b[:4] != b'LOCH':
        raise DataError('%s 開頭不是 LOCH' % os.path.basename(path))
    # 檔頭長度自己記在 +0x04,照它跳到下一段,不要寫死 16 或 32。
    hdrlen = struct.unpack('<I', b[4:8])[0]
    i = hdrlen
    if b[i:i + 4] != b'LOCI':
        raise DataError('找不到 LOCI 索引段')
    idxoff, count = struct.unpack('<II', b[i + 4:i + 12])
    # 索引表要真的整份在檔案裡。少了這一關,截斷得比較狠的檔會在下面那個
    # list comprehension 撞出 struct.error 的完整 traceback —— 那看起來像
    # 程式的錯,實際上是使用者的檔壞了。2026-09-05 實測:把 42,983 bytes 的
    # IGENG.LOC 截成 8,000 bytes,原本會丟 struct.error traceback。
    if count <= 0 or i + 16 + count * 4 > len(b):
        raise DataError('%s 的索引表宣告 %d 條,檔案只有 %d bytes —— 這個檔是半截的。'
                        % (os.path.basename(path), count, len(b)))
    # 每筆索引 4 bytes,只取前 2 bytes 的字串編號。後 2 bytes 本工具用不到,
    # 而**整個索引段等一下會被原樣搬回去**,所以不需要知道它是什麼。
    ids = [struct.unpack('<HH', b[i + 16 + k * 4:i + 20 + k * 4])[0] for k in range(count)]
    L = b.find(b'LOCL', idxoff)
    if L < 0:
        raise DataError('找不到 LOCL 文字段')
    # 位移表也一樣要整份在檔案裡,理由同上。
    if L + 16 + count * 4 > len(b):
        raise DataError('%s 的位移表宣告 %d 條,檔案只有 %d bytes —— 這個檔是半截的。'
                        % (os.path.basename(path), count, len(b)))
    # 位移表:第 k 筆放在 LOCL + 16 + k*4,小端序 4 bytes。
    offs = [struct.unpack('<I', b[L + 16 + k * 4:L + 20 + k * 4])[0] for k in range(count)]
    strs = []
    for o in offs:
        # 位移是相對於 LOCL 的,所以真正的檔案位置要加上 L。
        s = L + o
        # 位移指到檔案外面 = 這個檔是半截的。不擋的話 b[s:e] 是空切片,
        # 那一條就安靜地變成空字串 —— 而 cmd_fix 的複驗比的是「打算寫進去的
        # 內容」,拿同一份被讀壞的資料自比一定相同,比不出這種空。
        # 2026-09-05 實測:把 IGENG.LOC 截成 20,000 bytes,1,663 條裡有 1,197 條
        # 被讀成空字串,--fix --apply 照樣印「1663 條字串全部讀得回來 ✅」exit 0。
        if s >= len(b):
            raise DataError('%s 的第 %d 條字串位移指到檔案外面(%d ≥ %d),這個檔是半截的。'
                            % (os.path.basename(path), len(strs) + 1, s, len(b)))
        e = s
        # UTF-16 一個字元 2 bytes,所以一次走 2 bytes,不是 1 bytes。
        # 結尾是「連續兩個 0x00」;只看一個 0x00 會把中文字砍成半個。
        while e + 1 < len(b) and b[e:e + 2] != b'\x00\x00':
            e += 2
        # 走到檔尾都沒遇到那兩個 0x00 = 最後一條被切掉了。
        # (本站手邊四份語系檔 —— 中文 IGENG/FEENG 與剛安裝好的英文原版兩份 ——
        #  合計 16,035 條,沒有一條走不到結尾,所以這一關不會誤傷正常的檔。)
        if b[e:e + 2] != b'\x00\x00':
            raise DataError('%s 的第 %d 條字串沒有結尾標記,這個檔是半截的。'
                            % (os.path.basename(path), len(strs) + 1))
        strs.append(b[s:e].decode('utf-16-le', errors='replace'))
    return b, dict(hdrlen=hdrlen, idxoff=idxoff, count=count, LOCL=L,
                   ids=ids, offs=offs, strs=strs)


def strip_fmt(s, keep):
    """把 CFMT 格式符拿掉,只留前 keep 個。中文字一律不動。

    做法是照 CFMT 從左掃到右:格式符**之間**的文字原封不動接回去,
    第 keep + 1 個之後的格式符就不接。所以:
      · 留下來的是**最前面**那幾個,不是挑型別最像英文版的那幾個
      · 中文字一個都碰不到(中文不在 CFMT 任何一個字元類裡);但被拿掉的
        不一定只有 % 開頭那幾個字元:旗標裡含半形空白與小數點,'%1 i' 這種
        「%數字 + 空白 + 英文字母」會被整段當成格式符(見 CFMT 那一段)
      · keep = 0 就是整條的格式符全部拿掉
      · 中文原本比英文少的情況不會走到這裡(analyse 只挑「多」的那個方向)
    """
    out = []
    n = 0
    pos = 0
    for m in CFMT.finditer(s):
        # 先把上一個格式符結束到這個格式符開始之間的原文接回去。
        out.append(s[pos:m.start()])
        if n < keep:
            out.append(m.group(0))
        n += 1
        pos = m.end()
    # 最後一個格式符之後還有一段原文,不要漏掉。
    out.append(s[pos:])
    return ''.join(out)


def analyse(root):
    """回傳 [(檔名, 路徑, [(索引, 編號, 英文, 中文, 英要, 中要)])]

    把中文那兩個 .LOC 跟英文版逐條對帳,挑出「中文要的參數比英文多」的條目。
    整支腳本只有這裡在做判斷,cmd_show 與 cmd_fix 都只是拿它的結果去印或去改。

    ⚠️ 對帳是照**字串編號**配對,不是照順序。兩邊的條數與排列可以不同,
       照順序比會整排錯位,而錯位之後每一條看起來都「對不上」。
       編號在英文版裡找不到的直接跳過:沒有基準就不判,不猜。

    ⚠️ 只挑 nc > ne 這一個方向。反過來(中文比英文少)不會當,只會少印東西,
       所以完全不碰,理由見檔頭那一段。

    ⚠️ 只比數量,不比型別。中文 4 個、英文 2 個 → 砍到剩前 2 個;
       至於剩下那 2 個是不是 %s 對 %s,這裡沒有驗,本站也沒有驗過。
    """
    data = os.path.join(root, 'data')
    # 先確認給的真的是「遊戲安裝資料夾」。少了這一關,路徑打錯或把 data 本身
    # 當成遊戲資料夾,兩種最常見的手誤都會掉到下面那句「找不到英文語系資料夾」,
    # 把人引去翻一個根本不是問題的東西。
    if not os.path.isdir(data):
        raise DataError('%s 底下沒有 data 資料夾。\n'
                        '     要給的是遊戲安裝資料夾(它的下一層才是 data)。' % root)
    # 英文語系資料夾有兩種常見名字(2023 版與更早的版本),兩個都試。
    en_dir = None
    for cand in ('2023英文語系', '英文語系'):
        p = os.path.join(data, cand)
        if os.path.isdir(p):
            en_dir = p
            break
    # 沒有基準就整支停掉。「猜哪些格式符是多的」然後去改玩家的遊戲檔,
    # 是這支腳本唯一不可以做的事。
    if not en_dir:
        raise DataError('找不到英文語系資料夾(試過 data/2023英文語系、data/英文語系)\n'
                        '     沒有它就沒有基準可比,無法判斷哪些格式符是多的。')
    out = []
    for name in ('IGENG.LOC', 'FEENG.LOC'):
        live = os.path.join(data, name)
        base = os.path.join(en_dir, name)
        # 兩邊少了任何一邊就跳過這個檔(例如只蓋了其中一半的中文化)。
        # 那不是錯誤,只是這個檔沒得比。
        if not (os.path.isfile(live) and os.path.isfile(base)):
            continue
        _, C = parse(live)
        _, E = parse(base)
        # 把英文那份攤成 {字串編號: 字串},配對才不會受兩邊排列順序影響。
        emap = dict(zip(E['ids'], E['strs']))
        bad = []
        for k, (sid, s) in enumerate(zip(C['ids'], C['strs'])):
            if sid not in emap:
                continue
            # ne = 英文那一條要幾個參數(遊戲程式實際會傳幾個,以英文版為準)
            # nc = 中文那一條開口要幾個
            ne = len(CFMT.findall(emap[sid]))
            nc = len(CFMT.findall(s))
            # 中文開口比英文多 = 會去堆疊上撈不存在的參數 = 當機的那一種。
            # k(在字串表裡的第幾條)要一起記,因為 cmd_fix 是照 k 回去改的。
            if nc > ne:
                bad.append((k, sid, emap[sid], s, ne, nc))
        out.append((name, live, bad))
    return out


def rebuild(path, C, newstrs):
    """照原結構寫回。字串段重排,索引段的偏移跟著更新。

    只有「位移表之後的字串本體」會被重寫。前面那一整塊(LOCH 檔頭、
    LOCI 索引段、LOCL 的表頭與位移表)是整段原樣搬過去的,所以字串編號、
    條數、每一條的順序都不會變,遊戲照編號要字串一定還要得到。

    位移表本身長度不變(條數沒變),所以是**就地改裡面的數字**,不是重寫它。

    ⚠️ 位移是「相對於 'LOCL' 那四個字的開頭」算的,不是相對於檔案開頭。
       所以下面那一行寫的是 len(head) - L + len(body),先扣掉 L。
    ⚠️ 字串長度沒有記在任何地方,結尾就是那兩個 0x00。所以字串變短之後
       只要位移表跟著改,整個檔就會自然變小,不需要補空白對齊。
    ⚠️ newstrs 是**整份**字串表,不是只有要改的那幾條;沒改到的那些會照原樣
       重新編碼一次寫回去(見 parse 說明裡關於 U+FFFD 的那一段)。
    """
    with open(path, 'rb') as f:
        b = bytearray(f.read())
    L = C['LOCL']
    # head = 從檔案開頭一路到位移表的最後一筆為止,原樣不動。
    # 位移表就在這一塊的尾巴,等一下用 pack_into 就地改。
    head = bytes(b[:L + 16 + C['count'] * 4])
    body = bytearray()
    offs = []
    for s in newstrs:
        # 接之前先算出「這一條會落在哪」,而且要換算成相對於 LOCL 的位移。
        offs.append(len(head) - L + len(body))
        body += s.encode('utf-16-le') + b'\x00\x00'
    out = bytearray(head) + body
    # 位移表就地更新:第 k 筆寫在 LOCL + 16 + k*4,小端序 4 bytes。
    for k, o in enumerate(offs):
        struct.pack_into('<I', out, L + 16 + k * 4, o)
    # ⚠️ 以前這裡是 open(path, 'wb') 直接寫正本 —— 那一行的第一件事就是把
    #    使用者的遊戲檔截成 0 bytes,寫到一半斷掉就回不去了。改成先寫
    #    同資料夾的亂數暫存檔、fsync、讀回來比對,再 os.replace 換上。
    return _atomic_write_bytes(path, bytes(out), 'new')


def _selfcmd():
    """印「下一步該打什麼」時用的腳本名稱。

    ⚠️ 這裡以前是 os.path.basename(__file__),不管人從哪裡呼叫都印
       mvp_fix_loc.py。腳本不在目前資料夾時(還放在「下載」裡、或用完整路徑
       呼叫),把印出來那一行複製貼上會得到 can't open file —— 而這一課的速解
       正好把 can't open file 列為要避開的陷阱。
       sys.argv[0] 就是他剛才實際打的那個字,照抄一定跑得動。
    """
    p = sys.argv[0]
    # argv[0] 不一定是一個真的檔案:空字串、或用 python3 - 從標準輸入餵進來時
    # 它會是 '-'。印那個沒有意義,退回檔名。
    if not p or not os.path.isfile(p):
        p = os.path.basename(__file__)
    # 路徑含空白時要加引號,不然複製過去會被 shell 拆成兩個參數。
    return '"%s"' % p if ' ' in p else p


def cmd_show(root):
    """不加旗標時跑的就是這個:只讀、只印,一個位元組都不寫。

    每個檔先印有幾條對不上,再列前 8 條:上面一行是英文版長什麼樣、要幾個參數,
    下面一行是中文版長什麼樣、要幾個參數。兩行對齊擺,差別一眼就看得出來。

    只截前 34 個字是為了讓兩行對得齊,**不是完整內容**。
    要看完整的,把那個編號拿去 reference/loc-strings 那一頁查。
    """
    res = analyse(root)
    total = 0
    for name, live, bad in res:
        print('  %s  %s 條會讓遊戲拿到不存在的參數' % (name, len(bad)))
        total += len(bad)
        # 只列前 8 條。全部列出來會洗版(本站量到合計 168 條),
        # 而看 8 條就足以判斷「這確實是同一種毛病」。
        for k, sid, e, c, ne, nc in bad[:8]:
            print('     編號 %-6d 英「%s」要 %d 個' % (sid, e[:34], ne))
            print('     %-11s 中「%s」要 %d 個' % ('', c[:34], nc))
        if len(bad) > 8:
            print('     …還有 %d 條' % (len(bad) - 8))
        print()
    print('  合計 %d 條。' % total)
    if total:
        print('  要修的話(中文字一個都不會動,只拿掉多出來的格式符):')
        print('    python3 %s "%s" --fix' % (_selfcmd(), root))


def _report_partial(root):
    """中途出事的時候,逐檔講清楚「已改 / 沒改」,並且印還原指令。

    ⚠️ 這裡**不靠記帳,直接量檔案**:拿每個 .LOC 跟它自己的 .locbak 比一次。
       記帳會說謊 —— 複驗沒過那條路會先改檔、再自動還原回去,_WRITTEN 裡
       兩次都在,可是磁碟上其實已經回到原點了。量的話就不會弄錯。
       (沒有備份可比的檔就不列:那代表這一輪根本沒輪到它。)

    什麼時候會走到這裡:兩個語系檔是**依序**處理的,第二個檔中途出事
    (磁碟滿、外接碟被拔掉、按了 Ctrl-C)的時候,第一個檔已經改好了。
    原本的訊息只講得到出事的那一個,看的人不會知道另一個已經被動過。
    """
    # ⚠️ 光靠量也會說謊,所以要兩個條件一起看。
    #    只量的話:使用者資料夾裡本來就擺著一份**不相干或半截的** .locbak
    #    (那正是 _check_reusable_backup 擋下來的情況),量出來一定「不一樣」,
    #    這裡就會誣賴自己改過檔 —— 而那一條路是一個位元組都沒有寫的。
    #    所以先問記帳「這一輪到底有沒有換過任何一個遊戲檔」,有才往下量。
    touched = [p for p in list(_WRITTEN) + list(_REPLACING)
               if not p.endswith(BACKUP)]
    if not touched:
        return
    data = os.path.join(root, 'data')
    changed, same = [], []
    for name in ('IGENG.LOC', 'FEENG.LOC'):
        live = os.path.join(data, name)
        bak = live + BACKUP
        if not os.path.isfile(live):
            continue
        if not os.path.isfile(bak):
            # 沒有備份 = 這一輪一定沒輪到它。備份一律在 rebuild **之前**才做,
            # 所以「連備份都還沒有」等於「一個位元組都還沒寫過」。
            same.append(name)
            continue
        try:
            with open(live, 'rb') as f1, open(bak, 'rb') as f2:
                # 先比長度再比內容:zip 逐位元組比會在短的那一邊就停。
                if os.path.getsize(live) != os.path.getsize(bak) or f1.read() != f2.read():
                    changed.append(name)
                else:
                    same.append(name)
        except OSError:
            # 連讀都讀不到就不要猜。少講一行,好過講錯一行。
            continue
    if not changed:
        return
    print()
    print('  ⚠️ 中途出事了。剛才實地跟備份比對過,到目前為止:')
    for nm in changed:
        print('       已改 %s(跟 %s%s 不一樣了)' % (nm, nm, BACKUP))
    for nm in same:
        print('       沒改 %s' % nm)
    print('  要回到原本的樣子:')
    print('    python3 %s "%s" --restore' % (_selfcmd(), root))


def cmd_fix(root, apply_it):
    """真正的內容在 _cmd_fix_inner,這一層只負責「中途出事要把話講完」。

    ⚠️ 接的是 BaseException 不是 Exception:Ctrl-C(KeyboardInterrupt)與
       SystemExit 都不是 Exception 的子類。只接 Exception 的話,最常見的那一種
       中斷(使用者按 Ctrl-C)反而走不到誠實路徑。
       接完一律 raise 回去,exit code 與原本的訊息一個字都沒有變 ——
       這一層只是在原本的訊息**之前**多印一段逐檔的「已改 / 沒改」。
       (Ctrl-C 另外還會走 main() 的 _interrupted():那一段講的是
        os.replace 做過幾次,跟這裡的逐檔比對互補,不衝突。)
    """
    try:
        return _cmd_fix_inner(root, apply_it)
    except BaseException:
        try:
            _report_partial(root)
        except Exception:
            # 報告自己出事,絕對不可以蓋掉原本那個錯 —— 那才是使用者要看的。
            # 少一段補充說明,好過把真正的原因換成「報告程式壞了」。
            pass
        raise


def _cmd_fix_inner(root, apply_it):
    """預覽或真的修。沒加 --apply 就只印,加了才會備份 → 重寫 → 複驗。

    兩個語系檔各自處理,而且是各自備份、各自複驗,所以其中一個沒東西要修
    也不影響另一個。

    順序不能換:備份一定在寫之前,複驗一定在寫之後。
    備份只做第一次(.locbak 已經存在就沿用),所以重複執行 --fix --apply
    也還是回得到最原始那一份。

    ⚠️ 但「沿用」之前要先驗那份舊備份完不完整,而且是**兩個檔一起先驗完**
       才開始寫第一個檔(見 _check_reusable_backup)。壞掉的備份等於沒有備份,
       驗不過就丟 DataError 整支停下來,一個位元組都不會落地。
    ⚠️ 同一輪先驗的還有「正本與備份是不是符號連結」(_reject_symlink)。
       這一道也要排在動筆之前:沿著連結寫下去會改到遊戲資料夾外面的檔案。
    ⚠️ 複驗沒過**不是印一個 ❌ 就算了**。2026-09-05 之前 ok 只決定印 ✅ 還是 ❌,
       main() 照樣回 0 —— 看輸出的人會以為成功了,而壞掉的檔就留在遊戲資料夾裡。
       現在改成:立刻拿(剛剛才驗過的)備份自動還原,備份留著不刪,
       最後用 SystemExit 收尾,exit code 非 0。
    """
    res = analyse(root)
    # 要真的寫檔的話,先把「已經存在的備份」兩個一起驗過,再開始寫第一個檔。
    # 分成兩段是刻意的:兩個語系檔是依序處理的,邊驗邊寫的話,第二個檔的備份
    # 壞掉時第一個檔已經被改掉了,訊息就只能說一半。先驗完才寫,
    # 「停在這裡,一個位元組都沒有改」這句話才是真的。
    if apply_it:
        for name, live, bad in res:
            if bad:
                # 順序不能換:符號連結那一道要在讀備份之前,因為連結指到的
                # 東西可能根本不是這個資料夾裡的檔。
                _reject_symlink(live, '遊戲檔')
                _reject_symlink(live + BACKUP, '備份檔')
                _check_reusable_backup(live + BACKUP)
    changed_any = False
    preview_any = False
    verify_failed = []
    for name, live, bad in res:
        if not bad:
            print('  %s  沒有要修的' % name)
            continue
        # analyse 只回報「哪幾條有問題」,不回傳整份結構,所以這裡重讀一次,
        # 拿完整的 C(字串表、位移表、LOCL 在哪)給 rebuild 用。
        _, C = parse(live)
        # 先整份複製一份,再只改要改的那幾條。沒問題的那些原樣留著。
        newstrs = list(C['strs'])
        for k, sid, e, c, ne, nc in bad:
            # ne 是英文那一條要幾個參數,砍到跟它一樣多為止。
            # k 是「在字串表裡的第幾條」,由 analyse 一路帶過來。
            newstrs[k] = strip_fmt(c, ne)
        if not apply_it:
            # 預覽:兩個語系檔都要列出來,所以是 continue 不是 return
            print('  %s  會修 %d 條(預覽,沒有動到檔案)' % (name, len(bad)))
            for k, sid, e, c, ne, nc in bad[:3]:
                print('        編號 %-6d 「%s」→「%s」' % (sid, c[:26], newstrs[k][:26]))
            preview_any = True
            continue
        # 備份只做第一次。第二次執行時 .locbak 裡放的仍然是最原始那一份,
        # 而不是上一次改完的結果,所以 --restore 一定回得到原點。
        bak = live + BACKUP
        if not os.path.exists(bak):
            _atomic_copy(live, bak)          # 備份原檔
        size = rebuild(live, C, newstrs)
        changed_any = True
        # 複驗:重讀剛寫出去的檔,三件事都要對得上:
        # 條數一樣、每一條的字串編號一個沒變(遊戲是照編號要字串的)、
        # 每一條內容跟我們打算寫進去的完全相同。
        # 「寫完就當作成功」是這類工具最常見的謊。
        _, C2 = parse(live)
        # 三件事都要對得上,而且**三件都要參與判斷** ——
        # 只拿來印一個符號的檢查,等於沒有檢查。
        why = []
        if C2['count'] != C['count']:
            why.append('條數 %d → %d' % (C['count'], C2['count']))
        if C2['ids'] != C['ids']:
            why.append('字串編號變了')
        if C2['strs'] != newstrs:
            why.append('內容跟打算寫的不一樣')
        ok = not why
        print('  %s  修了 %d 條 → %s bytes' % (name, len(bad), '{:,}'.format(size)))
        print('     備份 → %s' % os.path.basename(bak))
        if ok:
            print('     複驗:%d 條字串全部讀得回來、編號一個沒變 ✅' % C2['count'])
            for k, sid, e, c, ne, nc in bad[:3]:
                print('        編號 %-6d 「%s」→「%s」' % (sid, c[:26], newstrs[k][:26]))
            continue
        # 複驗沒過 = 寫出去的東西不是我們打算寫的。備份是這一輪動筆之前
        # 才驗過的完整檔,所以直接拿它還原,不留一個壞掉的正本給玩家。
        # 備份**不刪**:自動還原也可能失敗,那時候它是唯一的副本。
        print('     複驗:❌ 對不上(%s)' % '、'.join(why))
        print('     正在拿備份自動還原……')
        try:
            _restore_from_backup(bak, live)
            _verify_restored(bak, live)
        except SystemExit as e2:
            print('     ❌ 自動還原也失敗了:')
            for ln in str(e2).splitlines():
                print('        %s' % ln.lstrip())
            print('     備份留著沒有刪:%s' % bak)
        else:
            print('     ✅ 已經還原回原本的樣子(備份留著沒有刪)')
        verify_failed.append(name)
    if verify_failed:
        # 這裡一定要用非 0 的 exit code 結束。以前印完 ❌ 就往下走,
        # main() 照樣回 0 —— 對於用腳本串起來的人來說,那等於沒有失敗。
        raise SystemExit(
            '  %s 的複驗沒過,已經照上面說的處理過了。\n'
            '  請把上面整段貼到回報頁,並且先不要進遊戲。'
            % '、'.join(verify_failed))
    if preview_any:
        print()
        print('  以上是預覽,還沒有動到任何檔案。')
        print('  確定要修的話,在剛才那一行最後面加上 --apply')
    if changed_any:
        print()
        print('  進遊戲,用那座會當的球場試一次。沒改善就還原:')
        print('    python3 %s "%s" --restore' % (_selfcmd(), root))


def cmd_restore(root):
    """把兩個 .LOC 的備份蓋回去,還原成功一個就刪掉一個備份。

    只還原「找得到備份」的那些。只修過其中一個檔的話,另一個本來就沒有備份
    可以還原,那不是錯誤;兩個都沒有才印「沒有東西要還原」。

    兩個檔各走各的:IGENG 的備份壞掉不會把 FEENG 一起帶走,FEENG 的備份
    如果是好的照樣會被還原。最後才用 SystemExit 收尾(exit code 1),
    訊息裡會寫哪個沒還原、哪個已經還原好了。

    刪備份是刻意的:留著會讓下一次 --fix 誤以為「已經備份過了」而沿用一份
    已經沒有意義的檔。還原完等於回到出發點,下一次改會重新備份一次。
    ⚠️ 但刪之前一定要先 _verify_restored 逐位元組比過 —— 那是使用者唯一的副本,
       比不過就留著不刪。
    """
    data = os.path.join(root, 'data')
    n = 0
    failed = []
    for name in ('IGENG.LOC', 'FEENG.LOC'):
        live = os.path.join(data, name)
        bak = live + BACKUP
        if os.path.exists(bak):
            # ⚠️ 2026-08-30 上線前資安稽核修:這裡原本是 raw read/write,
            #    **完全繞過本檔的 _restore_from_backup**,等於一道防線都沒有。
            #    實測拿前 1/8 的半截備份還原,416,753 bytes 的語系檔變成 52,094,
            #    而且印「✅ 已還原」—— 更糟的是下一行就把備份刪掉,
            #    唯一的副本也沒了。改成走那個函式(它會擋掉截斷的備份)。
            try:
                _restore_from_backup(bak, live)
                # 還原成功才算成功:蓋回去之後跟備份逐位元組比一次,
                # 比過了才刪備份。「寫完就當作成功」是這類工具最常見的謊。
                _verify_restored(bak, live)
            except SystemExit as e:
                # ⚠️ 一個檔的備份壞掉,不可以把另一個檔一起帶走。
                #    這裡以前是讓 SystemExit 直接往上飛,而迴圈是先 IGENG 後 FEENG,
                #    所以 IGENG 的備份壞掉時整支就結束了 —— FEENG 就算備份是好的
                #    也永遠不會被還原,畫面上連 FEENG 這三個字都不會出現。
                #    2026-09-05 實測過:FEENG 那份 219,421 bytes 的完好備份原封不動,
                #    正本停在被改過的狀態。改成記下來、繼續處理下一個檔。
                print('  ❌ %s 沒有還原:' % name)
                # 訊息本身是多行的,每一行都對齊到同一個縮排再印,
                # 不然第二行以後會比第一行還靠左,看起來像另一段話。
                for ln in str(e).splitlines():
                    print('     %s' % ln.lstrip())
                failed.append(name)
                continue
            os.remove(bak)
            print('  ✅ 已還原 %s' % name)
            n += 1
    if failed:
        # 還是要用非 0 的 exit code 結束,但話要講完整:哪些還原了、哪些沒有。
        raise SystemExit(
            '  %s 沒有還原,%s。\n'
            '  沒還原的那些,備份都還留著沒有刪 —— 原因與該怎麼辦寫在上面那幾行。'
            % ('、'.join(failed),
               ('另外 %d 個檔已經還原好了' % n) if n else '沒有任何檔被還原'))
    if not n:
        print('  找不到備份,沒有東西要還原。')


def _interrupted(restoring):
    """被 Ctrl-C 打斷時,照登記說清楚遊戲檔有沒有被動到。

    ⚠️ 「什麼都沒有動到」這句話只有在 os.replace 一次都沒做過的時候才能說。
       猜的不算 —— 所以每一次 os.replace 成功之後都會把目的檔記進 _WRITTEN,
       這裡讀的就是那份紀錄。

    四種說法對應四種真實狀態,exit code 都是 130(shell 慣例:128 + SIGINT):
      · **正在換某個檔** → 不知道換完了沒有,請 --restore 或自己跟備份比對
      · 一次都沒換過   → 什麼都沒有動到
      · 只換過備份檔   → 遊戲檔沒動,只是多了一份備份
      · 換過遊戲檔     → 遊戲檔已經改了,要回到原點就 --restore

    第一種是 2026-09-06 補的保險。_NoInterrupt 已經讓「換完了但還沒登記」
    幾乎不可能發生(那兩行中間收到的 Ctrl-C 會被延後),但「幾乎」不是「不會」:
    signal.signal 在非主執行緒裝不上去,那時候窗口還開著。窗口開著的時候
    這支腳本要說「我不確定」,不可以說「沒動到」。
    """
    live = [p for p in _WRITTEN if not p.endswith(BACKUP)]
    # 「正在換」而且還沒登記成「已換」的,才是不確定的那些。
    busy = [p for p in _REPLACING if p not in _WRITTEN]
    if busy:
        print()
        print('  ⚠️ 被 Ctrl-C 中斷,而中斷的時候**正在替換**下面這些檔:')
        for p in busy:
            print('       %s' % p)
        print('  換名本身是原子的,所以它要嘛還是原本那份、要嘛已經是新的完整檔,')
        print('  不會有半截檔 —— 但這支腳本無法確定是哪一種。請用 --restore 還原,')
        print('  或自己拿 .locbak 跟它比對一次:')
        print('    python3 %s "<遊戲資料夾>" --restore' % _selfcmd())
        if live:
            print('  另外,下面這些遊戲檔可以確定已經換過了:')
            for p in live:
                print('       %s' % p)
        print()
        return 130
    print()
    if live and restoring:
        # 還原途中被打斷。os.replace 是原子的,所以每個檔要嘛還是被改過的那份、
        # 要嘛已經是還原好的完整檔,不會有半截檔。再跑一次 --restore 是安全的。
        print('  被 Ctrl-C 中斷。下面這些檔已經還原完成(換名是原子的,沒有半截檔):')
        for p in live:
            print('       %s' % p)
        print('  其餘的檔還沒還原,再跑一次 --restore 就好。')
    elif live:
        print('  ⚠️ 被 Ctrl-C 中斷,而且**遊戲檔已經被改過**了:')
        for p in live:
            print('       %s' % p)
        print('  要回到原本的樣子:')
        print('    python3 %s "<遊戲資料夾>" --restore' % _selfcmd())
    elif _WRITTEN:
        print('  被 Ctrl-C 中斷。遊戲檔一個位元組都沒有動到,只是多了備份檔:')
        for p in _WRITTEN:
            print('       %s' % p)
    else:
        print('  被 Ctrl-C 中斷。什麼都沒有動到。')
    print()
    return 130


def main():
    """把命令列參數接成一個動作,並且把例外翻成一行中文。

    判斷順序是 --restore → --fix → 什麼都沒加(只看)。
    --apply 不是動作,是「--fix 要不要真的寫檔」的開關;
    單獨加 --apply 會走到「只看」那一條,什麼都不會被寫。
    ⚠️ 它管不到 --restore。--restore 排在最前面而且自己就會寫檔,
       不加 --apply 一樣會把 .locbak 蓋回去、蓋完再把備份刪掉。

    只接 DataError,它代表「你的檔案或參數有問題」,印一行中文、回 exit code 2。

    ⚠️ 這裡不是「DataError 一行中文 / 其他全部 traceback」的二分法,SystemExit
    走的是第三條路:--restore 的 _restore_from_backup() 擋下壞掉的備份時丟它,
    參數打錯時 argparse 也丟它。兩種都不經過下面這個 except,訊息由 Python
    自己印到 stderr,也都不會有 traceback。

    2026-09-03 在本站測試機那份 data/FEENG.LOC 上實測(416,753 bytes,
    與同資料夾那份舊備份逐位元組相同;
    備份刻意截成前 1/8 的 52,094 bytes):
      · --restore 印「這份備份是壞的,不敢拿它覆蓋 …」,echo $? = 1
        (2026-09-05 起訊息前面多一行「❌ IGENG.LOC 沒有還原:」,而且會**繼續**
         處理 FEENG.LOC,最後才由 cmd_restore 自己丟 SystemExit 收尾,
         exit code 一樣是 1)
      · 路徑指到不存在的資料夾走 DataError,印「停下來了:…」,echo $? = 2
      · 不給任何參數,argparse 印用法,echo $? = 2

    DataError 與 SystemExit 以外的例外才會丟出完整 traceback:那是程式的錯,
    不該被包裝成一句好看的中文而讓人以為是自己弄錯。

    第四條路是 Ctrl-C:接住 KeyboardInterrupt、照登記說清楚
    「遊戲檔到底有沒有被動到」,回 exit code 130(shell 的慣例:128 + SIGINT)。
    2026-09-05 之前這裡沒有接,Python 會印 KeyboardInterrupt 的 traceback;
    而更早的版本在某些路徑上是安靜地回 0 —— 那等於告訴使用者「沒事」,
    但那句話有一半的機率是假的。
    ⚠️ 2026-09-06:登記從兩態變三態(還沒動 / 正在換 X / 已換 X),而且
    「換名 + 登記」被包進 _NoInterrupt。細節與實測見檔頭「安全網」那一段。
    """
    ap = argparse.ArgumentParser(
        description='把中文語系檔裡會讓遊戲當掉的格式符拿掉(中文字不動)',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='''
照順序做:

  1. 先看有幾條(唯讀,一個位元組都不寫)
     python3 mvp_fix_loc.py "<遊戲資料夾>"

  2. 預覽修法(還是不寫)
     python3 mvp_fix_loc.py "<遊戲資料夾>" --fix

  3. 確定了才真的修
     python3 mvp_fix_loc.py "<遊戲資料夾>" --fix --apply

  沒改善就還原:
     python3 mvp_fix_loc.py "<遊戲資料夾>" --restore

⚠️ --fix 沒有加 --apply 就只是預覽,一個位元組都不會落地。
⚠️ --restore 不看 --apply,打了就直接寫回去,而且會把備份用掉。
''')
    ap.add_argument('path', help='遊戲安裝資料夾(它的下一層才是 data)')
    ap.add_argument('--fix', action='store_true', help='修(不加 --apply 只是預覽)')
    ap.add_argument('--apply', action='store_true', help='真的寫入(沒加就只是預覽)')
    ap.add_argument('--restore', action='store_true', help='還原')
    ap.add_argument('--selftest', action='store_true',
                    help='自我測試(不需要遊戲資料夾,也不碰任何遊戲檔)')
    # ⚠️ --selftest 要走在 parse_args() **之前**:path 是必填的位置參數,
    #    讓 argparse 先看到就會因為缺 path 而印用法。上面還是把它加進 parser,
    #    那是為了讓 --help 列得到它;真正接住它的是下面這兩行。
    #    這樣「不給遊戲資料夾 → argparse 印用法、exit code 2」那個行為
    #    一個字都沒有變。
    if '--selftest' in sys.argv[1:]:
        return selftest()
    args = ap.parse_args()
    try:
        print()
        if args.restore:
            cmd_restore(args.path)
        elif args.fix:
            cmd_fix(args.path, args.apply)
        else:
            cmd_show(args.path)
        print()
        return 0
    except DataError as e:
        print('  停下來了:%s\n' % e)
        return 2
    except KeyboardInterrupt:
        return _interrupted(args.restore)


def _fake_loc(pairs):
    """造一份最小但結構完整的 .LOC,只給 --selftest 用,完全不碰遊戲檔。

    版面照本站測試機那份 data/IGENG.LOC 量出來的抄(xxd 前 64 bytes):

        00000000  4c4f 4348 1400 0000 0100 0000 0100 0000   LOCH,檔頭長 20
        00000010  e418 0000 4c4f 4349 d018 0000 3006 0000   +0x10 是 LOCL 的位移
                                                            接著 LOCI、idxoff、條數

    所以 +0x10 那 4 bytes 一定要放 LOCL 的**絕對**位移 ——
    _restore_from_backup 的第 3 道就是照它走的,放錯的話連完好的備份都會被擋。
    """
    count = len(pairs)
    hdrlen = 20
    locl_off = hdrlen + 16 + count * 4          # LOCI 佔 16 + 索引表
    idxoff = locl_off - hdrlen                  # 只當「從這裡往後找 LOCL」的起點
    head = b'LOCH' + struct.pack('<IIII', hdrlen, 1, 1, locl_off)
    idx = b''.join(struct.pack('<HH', sid, 0) for sid, _t in pairs)
    loci = b'LOCI' + struct.pack('<II', idxoff, count) + b'\x00' * 4 + idx
    body = b''
    offs = []
    base = 16 + count * 4                       # 位移是相對於 'LOCL' 的開頭算的
    for _sid, txt in pairs:
        offs.append(base + len(body))
        body += txt.encode('utf-16-le') + b'\x00\x00'
    locl = (b'LOCL' + b'\x00' * 8 + struct.pack('<I', count)
            + b''.join(struct.pack('<I', o) for o in offs) + body)
    return head + loci + locl


def selftest():
    """--selftest:自己造一份最小的語系檔來測,完全不碰任何遊戲檔。

    ⚠️ 為什麼每一道把關都要配一個「餌」:2026-08-29 本站踩過 ——
       防線壞掉了,測試照樣全綠。只有正向測試的話,通過只能證明
       「正常流程沒壞」,證明不了「不正常的東西真的被擋下來」。
       所以下面標成【餌】的每一塊,都是**故意做一件必須失敗的事**;
       它沒有失敗,才是出事了。

    測到的東西:
      正向 —— 自造的語系檔讀得出來、strip_fmt 砍對地方、analyse 挑對條數、
              --fix --apply 真的改對而且編號一個沒變、--restore 逐位元組回到原點、
              檔案權限沒有被還原動作改掉、os.replace 有被記進 _WRITTEN
      餌   —— 複驗對不上要自動還原並回非 0、半截舊備份在動筆之前就被擋、
              半截備份不可以還原得下去、還原途中 os.replace 出錯正本要原封不動
              而且不留暫存檔、`<備份檔>.part` 符號連結不可以被沿用、
              指向不存在目標的符號連結(exists() 看不到的那種)要被擋、
              正本本身是符號連結也要被擋、
              **Ctrl-C 落在「換名之後、登記之前」不可以說「什麼都沒有動到」**、
              **狀態真的不明的時候要照實說「正在替換」**、
              **兩個檔只改到一半要逐檔講「已改 / 沒改」**,
              而且一個位元組都沒寫的時候**不可以誣賴自己改過檔**

    ⚠️ 這個函式在 `python3 -O` 底下會拒跑並回 exit code 2(見開頭第一段)。
    """
    # ⚠️ 第一件事:python -O 會把 assert 整句拿掉。這支目前是自己 raise
    #    AssertionError(不是 assert 句),所以 -O 底下**這一版**其實還是真的在測;
    #    但只要以後有人補一行 assert 進來,-O 就會讓它變成假綠而沒有人發現。
    #    測試工具寧可拒跑,也不要印一行沒有根據的「全部通過」。
    if sys.flags.optimize:
        print('--selftest 不能在 python -O 下跑:'
              '-O 會把 assert 全部拿掉,測試會假綠。請拿掉 -O 再跑一次。')
        return 2

    import contextlib
    import io as _io

    tally = {'n': 0, 'bait': 0}

    def ck(cond, msg, is_bait=False):
        tally['n'] += 1
        if is_bait:
            tally['bait'] += 1
        if not cond:
            raise AssertionError(msg)

    def quiet(fn, *a):
        with contextlib.redirect_stdout(_io.StringIO()):
            return fn(*a)

    # 英文基準不帶格式符,中文那兩條分別多要 2 個與 4 個參數 ——
    # 就是檔頭講的 23484 / 23505 那兩條的縮影。
    EN = [(23484, 'Right Field Camera'), (23505, 'Argument Intensity')]
    CN = [(23484, '   -  %s得分。%c'), (23505, ' - %s盜%d%s壘成功。%c')]
    FIXED = ['   -  得分。', ' - 盜壘成功。']

    root = tempfile.mkdtemp(prefix='mvp_fix_loc_selftest_')

    def make_game(both=False):
        """每一塊測試都用全新的一份,免得上一塊的殘留影響下一塊。

        both=True 會連 FEENG.LOC 也造出來 —— 「兩個檔只改到一半」那一道餌
        需要真的有兩個檔可以改,只有一個的話它根本咬不到。
        """
        d = tempfile.mkdtemp(dir=root)
        en = os.path.join(d, 'data', '2023英文語系')
        os.makedirs(en)
        names = ('IGENG.LOC', 'FEENG.LOC') if both else ('IGENG.LOC',)
        for nm in names:
            with open(os.path.join(d, 'data', nm), 'wb') as f:
                f.write(_fake_loc(CN))
            with open(os.path.join(en, nm), 'wb') as f:
                f.write(_fake_loc(EN))
        return d, os.path.join(d, 'data', 'IGENG.LOC')

    def read(p):
        with open(p, 'rb') as f:
            return f.read()

    def junk(live):
        """資料夾裡有沒有留下我們自己的暫存檔(名字都是 .<檔名>.<標籤>-亂數)。"""
        pre = '.' + os.path.basename(live)
        return [x for x in os.listdir(os.path.dirname(live)) if x.startswith(pre)
                and not x.endswith(BACKUP)]

    # ── 正向:先證明正常流程真的會做到承諾的事 ──────────────────────
    # 少了這一段,下面的餌可能只是「因為整支都壞了」才過的。
    d, live = make_game()
    orig = read(live)
    _b, C = parse(live)
    ck(C['ids'] == [23484, 23505], '自造的語系檔讀不出正確的字串編號')
    ck(C['strs'] == [t for _s, t in CN], '讀出來的文字不對')
    ck(strip_fmt(CN[0][1], 0) == FIXED[0], 'strip_fmt 砍錯了(keep=0)')
    ck(strip_fmt(CN[1][1], 2) == ' - %s盜%d壘成功。', 'strip_fmt 沒有留下最前面 2 個')
    ck(len(analyse(d)[0][2]) == 2, 'analyse 應該挑出 2 條')

    os.chmod(live, 0o644)
    mode_before = os.stat(live).st_mode & 0o777
    del _WRITTEN[:]
    quiet(cmd_fix, d, True)
    ck(os.path.isfile(live + BACKUP), '--fix --apply 之後應該要有備份')
    ck(read(live + BACKUP) == orig, '備份放的不是原檔')
    _b2, C2 = parse(live)
    ck(C2['strs'] == FIXED, '修完的內容不對')
    ck(C2['ids'] == C['ids'], '字串編號被動到了')
    ck(live in _WRITTEN, 'os.replace 做過卻沒有記進 _WRITTEN —— Ctrl-C 就會說錯話')
    ck(not junk(live), '寫完不可以留下暫存檔:%r' % junk(live))

    quiet(cmd_restore, d)
    ck(read(live) == orig, '還原之後應該逐位元組回到原本的樣子')
    ck(not os.path.exists(live + BACKUP), '還原成功之後備份應該被刪掉')
    ck(os.stat(live).st_mode & 0o777 == mode_before, '還原不該改掉檔案權限')

    # ── 【餌】複驗對不上:要自動還原,而且不可以成功結束 ────────────────
    d, live = make_game()
    orig = read(live)
    real_rebuild = globals()['rebuild']

    def wrong_rebuild(path, C_, newstrs):
        # 故意只讓「內容」那一項對不上:結構、條數、編號全部正常,
        # 這樣前兩項都會放行,只有第三項抓得到 —— 餌才咬得到那一道。
        return real_rebuild(path, C_, ['我不是你要的東西'] + list(newstrs[1:]))

    globals()['rebuild'] = wrong_rebuild
    try:
        blocked = False
        try:
            quiet(cmd_fix, d, True)
        except SystemExit:
            blocked = True
    finally:
        globals()['rebuild'] = real_rebuild
    ck(blocked, '複驗對不上竟然還是成功結束 —— 防線失效', True)
    ck(read(live) == orig, '複驗沒過就要自動還原回原本的樣子', True)
    ck(os.path.isfile(live + BACKUP), '自動還原之後備份要留著不刪', True)

    # ── 【餌】半截的舊備份:要在動筆之前就被擋下來 ──────────────────
    d, live = make_game()
    orig = read(live)
    with open(live + BACKUP, 'wb') as f:
        f.write(orig[:40])
    del _WRITTEN[:]
    blocked = False
    try:
        quiet(cmd_fix, d, True)
    except DataError:
        blocked = True
    ck(blocked, '半截的舊備份竟然被沿用了 —— 防線失效', True)
    ck(read(live) == orig, '被擋下來的時候正本一個位元組都不可以動', True)
    ck(not _WRITTEN, '一個位元組都沒寫,_WRITTEN 就不該有東西', True)

    # ── 【餌】--restore 拿半截備份:要被擋,正本不可以被動到 ──────────
    d, live = make_game()
    orig = read(live)
    quiet(cmd_fix, d, True)
    fixed = read(live)
    ck(fixed != orig, '測試本身壞了:--apply 之後檔案應該不一樣')
    with open(live + BACKUP, 'wb') as f:
        f.write(orig[:40])
    blocked = False
    try:
        quiet(cmd_restore, d)
    except SystemExit:
        blocked = True
    ck(blocked, '半截的備份竟然還原得下去 —— 防線失效', True)
    ck(read(live) == fixed, '備份被擋下來的時候正本一個位元組都不可以動', True)

    # ── 【餌】還原途中 os.replace 出錯:正本要原封不動,不留暫存檔 ────
    # 這一道就是 shutil.copy2 的那個窗口:copy2 會先把正本截成 0 再寫,
    # 斷在中間就回不去了。原子版是「暫存檔寫壞了就丟掉」,正本沒被碰過。
    d, live = make_game()
    quiet(cmd_fix, d, True)
    fixed = read(live)
    real_replace = os.replace

    def boom(_a, _b):
        raise OSError(28, '磁碟滿了(自我測試故意丟的假錯誤)')

    os.replace = boom
    try:
        blocked = False
        try:
            quiet(cmd_restore, d)
        except (OSError, SystemExit):
            blocked = True
    finally:
        os.replace = real_replace
    ck(blocked, '還原途中出錯竟然沒有反應 —— 防線失效', True)
    ck(read(live) == fixed, '還原途中出錯,正本必須原封不動(不可以是 0 或半截)', True)
    ck(os.path.isfile(live + BACKUP), '還原沒成功就不可以把備份刪掉', True)
    ck(not junk(live), '失敗之後不可以留下暫存檔:%r' % junk(live), True)

    # ── 【餌】Ctrl-C 落在「換名之後、登記之前」:不可以說「什麼都沒動到」──
    # 這是 2026-09-06 補的那道。做法是把 os.replace 換成「先做真正的換名,
    # 再對自己送一個 SIGINT」—— 也就是把 Ctrl-C 精準地塞進那個窗口裡。
    # _NoInterrupt 會把它記著、等登記做完才丟出來,所以 _WRITTEN 一定有東西。
    # 沒有 _NoInterrupt 的話這裡會是空的,收尾就會說「什麼都沒有動到」——
    # 而磁碟上那個檔其實已經被換掉了。**那句話就是這道餌要抓的謊。**
    def _self_sigint():
        # raise_signal 是 3.8 才有的,而且跨平台(Windows 上 os.kill 送 SIGINT
        # 的語意跟 POSIX 不一樣)。沒有它就不假裝測過。
        signal.raise_signal(signal.SIGINT)
        # 送出去只是設一個旗標,Python 層的處理常式要等下一個檢查點才跑。
        # 燒掉一些位元組碼確保它真的跑到,不然這道餌會變成看運氣。
        for _ in range(200000):
            pass

    ctrlc_skipped = 0
    if hasattr(signal, 'raise_signal'):
        d, live = make_game()
        orig = read(live)
        real_replace = os.replace

        def replace_then_sigint(a, b):
            r = real_replace(a, b)
            if os.fspath(b) == live:      # 只在換「正本」的那一次咬,備份那次放行
                _self_sigint()
            return r

        del _WRITTEN[:]
        del _REPLACING[:]
        os.replace = replace_then_sigint
        try:
            bit = False
            try:
                quiet(cmd_fix, d, True)
            except KeyboardInterrupt:
                bit = True
        finally:
            os.replace = real_replace
        ck(bit, 'Ctrl-C 被吞掉了 —— _NoInterrupt 應該在離開那一段之後照常丟出來', True)
        ck(read(live) != orig, '測試本身壞了:換名應該已經真的做完了', True)
        ck(live in _WRITTEN,
           '換名做完了卻沒登記 —— 收尾會說「什麼都沒有動到」,那是假話', True)
        ck(not [p for p in _REPLACING if p not in _WRITTEN],
           '「正在換」的登記沒有收乾淨', True)
        # 收尾真的會承認嗎?把 _interrupted 的輸出接下來看。
        buf = _io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = _interrupted(False)
        ck(rc == 130, 'Ctrl-C 的 exit code 一定是 130', True)
        ck('已經被改過' in buf.getvalue(),
           '遊戲檔已經換過了,收尾卻沒有承認', True)
        ck('--restore' in buf.getvalue(), '換過檔就要印還原指令', True)

        # 反過來:Ctrl-C 落在**進入那一段之前**,就必須說「沒動到」。
        # 這裡用「換名時直接丟 KeyboardInterrupt、而且不做換名」來模擬
        # 最壞的情況(signal.signal 裝不上去、窗口還開著)。那時候誰也不知道
        # 換名做完了沒有,所以正確答案不是「沒動到」也不是「已改」,
        # 而是**照實說「正在替換」**。說「沒動到」才是說謊。
        d, live = make_game()
        orig = read(live)
        real_replace = os.replace

        def replace_is_ctrlc(a, b):
            if os.fspath(b) == live:
                raise KeyboardInterrupt
            return real_replace(a, b)

        del _WRITTEN[:]
        del _REPLACING[:]
        os.replace = replace_is_ctrlc
        try:
            bit = False
            try:
                quiet(cmd_fix, d, True)
            except KeyboardInterrupt:
                bit = True
        finally:
            os.replace = real_replace
        ck(bit, '測試本身壞了:應該要飛出 KeyboardInterrupt', True)
        ck(read(live) == orig, '這一種模擬裡正本不該被換掉', True)
        ck(live not in _WRITTEN, '沒換成就不可以登記成「已換」', True)
        ck(live in _REPLACING,
           '不知道換完了沒有的時候,「正在換」的登記不可以被撤掉', True)
        buf = _io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = _interrupted(False)
        ck(rc == 130, 'Ctrl-C 的 exit code 一定是 130', True)
        ck('正在替換' in buf.getvalue(),
           '狀態不明的時候要照實說「正在替換」,不可以說「什麼都沒有動到」', True)
        ck('什麼都沒有動到' not in buf.getvalue(),
           '狀態不明卻說「什麼都沒有動到」—— 這就是那句謊話', True)
        del _REPLACING[:]
    else:
        ctrlc_skipped = 2

    # ── 【餌】兩個檔只改到一半:要逐檔講「已改 / 沒改」並印還原指令 ────
    # 兩個語系檔是依序處理的,第二個檔中途出事的時候第一個已經改好了。
    # 原本的訊息只講得到出事的那一個 —— 看的人不會知道另一個已經被動過。
    d, live = make_game(both=True)
    feeng = os.path.join(d, 'data', 'FEENG.LOC')
    real_rebuild = globals()['rebuild']

    def rebuild_second_blows_up(path, C_, newstrs):
        if os.fspath(path) == feeng:
            raise OSError(28, '磁碟滿了(自我測試故意丟的假錯誤)')
        return real_rebuild(path, C_, newstrs)

    del _WRITTEN[:]
    del _REPLACING[:]
    globals()['rebuild'] = rebuild_second_blows_up
    buf = _io.StringIO()
    try:
        bit = False
        try:
            with contextlib.redirect_stdout(buf):
                cmd_fix(d, True)
        except OSError:
            bit = True
    finally:
        globals()['rebuild'] = real_rebuild
    said = buf.getvalue()
    ck(bit, '第二個檔寫壞了竟然沒有反應 —— 防線失效', True)
    ck('已改 IGENG.LOC' in said, '第一個檔已經改掉了,卻沒有逐檔講出來', True)
    ck('沒改 FEENG.LOC' in said, '沒改到的那個也要講,不然看的人得自己猜', True)
    ck('--restore' in said, '改到一半就要印還原指令', True)

    # 陰性對照:一個位元組都沒寫的那條路(舊備份是半截的,動筆之前就被擋),
    # **不可以**印「已改」。只靠「跟備份比對」會在這裡誣賴自己改過檔 ——
    # 因為那份備份本來就跟正本不一樣。
    d, live = make_game(both=True)
    orig = read(live)
    with open(live + BACKUP, 'wb') as f:
        f.write(orig[:40])
    del _WRITTEN[:]
    del _REPLACING[:]
    buf = _io.StringIO()
    blocked = False
    try:
        with contextlib.redirect_stdout(buf):
            cmd_fix(d, True)
    except DataError:
        blocked = True
    ck(blocked, '半截的舊備份竟然被沿用了 —— 防線失效', True)
    ck('已改' not in buf.getvalue(),
       '一個位元組都沒寫,卻說自己改過檔 —— 誣賴自己也是說謊', True)
    ck(read(live) == orig, '被擋下來的時候正本一個位元組都不可以動', True)

    # ── 【餌】符號連結三塊 ───────────────────────────────────────
    # Windows 沒開開發者模式時建不了符號連結,那三塊就跳過(並且說出來),
    # 不假裝測過。
    skipped = 0
    try:
        probe = os.path.join(root, '_probe')
        os.symlink(os.path.join(root, '_nowhere'), probe)
        os.remove(probe)
        can_link = True
    except (OSError, NotImplementedError, AttributeError):
        can_link = False

    if can_link:
        # (a) 可以預測的舊名字 `<備份檔>.part`:先擺一個指向資料夾外面的連結。
        #     舊版會沿著它把外面那個檔覆寫掉;現在的名字帶亂數,根本不會用到它。
        d, live = make_game()
        orig = read(live)
        outside = os.path.join(root, 'outside_a.txt')
        with open(outside, 'wb') as f:
            f.write(b'this file lives outside the game folder')
        keep = read(outside)
        os.symlink(outside, live + BACKUP + '.part')
        quiet(cmd_fix, d, True)
        ck(read(outside) == keep, '資料夾外面的檔被 <備份檔>.part 連結帶著改掉了', True)
        ck(read(live + BACKUP) == orig, '備份還是要正常做出來(陰性對照)', True)

        # (b) 備份檔本身是**指向不存在目標**的符號連結 ——
        #     os.path.exists() 對它回 False,這正是舊寫法看不到的那一種。
        d, live = make_game()
        orig = read(live)
        ghost = os.path.join(root, 'outside_b_does_not_exist.txt')
        os.symlink(ghost, live + BACKUP)
        ck(not os.path.exists(live + BACKUP),
           '測試本身壞了:dangling symlink 在 exists() 眼中應該是 False', True)
        blocked = False
        try:
            quiet(cmd_fix, d, True)
        except SystemExit:
            blocked = True
        ck(blocked, '備份檔是符號連結竟然照樣寫下去 —— 防線失效', True)
        ck(not os.path.exists(ghost), '沿著連結在資料夾外面生出了新檔案', True)
        ck(read(live) == orig, '被擋下來的時候正本一個位元組都不可以動', True)

        # (c) 正本自己是符號連結,指到資料夾外面。
        d, live = make_game()
        real = os.path.join(root, 'outside_c.LOC')
        os.replace(live, real)
        os.symlink(real, live)
        keep = read(real)
        blocked = False
        try:
            quiet(cmd_fix, d, True)
        except SystemExit:
            blocked = True
        ck(blocked, '正本是符號連結竟然照樣寫下去 —— 防線失效', True)
        ck(read(real) == keep, '沿著連結改到了遊戲資料夾外面的檔案', True)
    else:
        skipped = 3

    shutil.rmtree(root, ignore_errors=True)
    print('自我測試:全部通過(%d 道檢查,其中 %d 道是反向餌)'
          % (tally['n'], tally['bait']))
    if skipped:
        print('           這台機器建不了符號連結,%d 塊符號連結的餌跳過了 —— '
              '沒有測到,不算通過。' % skipped)
    if ctrlc_skipped:
        # 跳過就要說出來。不說的話,這一行「全部通過」會把「沒測到」
        # 混進「測過了」裡面 —— 那正是這一輪在修的那種謊。
        print('           這個 Python 沒有 signal.raise_signal(3.8 以前),'
              '%d 塊 Ctrl-C 的餌跳過了 —— 沒有測到,不算通過。' % ctrlc_skipped)
    return 0


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


# ─────────────────────────────────────────────────────────
#  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.
# ─────────────────────────────────────────────────────────