教學 › 中文版配大球場就跳出
中文版配大球場就跳出
很多人是退而求其次玩英文版的 —— 不是不想看中文, 是中文版一裝上比較大的球場就會在讀取條跑完的那一瞬間跳出,沒有任何錯誤訊息。 這件事卡了二十年,社群的結論是「球場檔不要超過 10MB」。 本站把執行檔反組譯到底,發現真正卡住的東西跟球場大小沒有關係, 而且兩個地方各改一個位元組就解決了。 這一頁把答案、完整的研究過程、以及我們走錯的十二條路全部寫出來。
難度
★☆☆ 一行指令
時間
約 3 分鐘
中文會不會少字
一個字都不會少
完全不動字型。一行指令可以還原。
🛟 動手之前:先完整備份整個遊戲資料夾
把整個遊戲資料夾複製一份到別的地方, 包括裡面存放紀錄的資料夾。不要挑檔案,整包複製最省事也最保險。
「紀錄」是指這些(它們跟遊戲檔混在同一個資料夾裡):
Support\MVP Baseball 2005\—— 遊戲自己建的, 裡面是設定(MVPOptions_Options.sav)與手把設定 (controller.cfg)。剛安裝好時沒有這個資料夾,玩過才出現。- 王朝、老闆模式、名單與玩家檔案的存檔(
*_Franchise.sav、*_Owner Mode.sav、*_Rosters.sav、*_Profile.sav)—— 沒有固定的資料夾名,你或你裝的模組包叫它什麼它就叫什麼。找不到就用這一行找:
dir /s /b "〔你的遊戲資料夾〕\*.sav"
find "〔你的遊戲資料夾〕" -name "*.sav"
上面第一行 Windows、第二行 Mac。 本站每一支工具都會自己備份它要動的那一個檔, 但那只保得住那一個檔 —— 整包備份保的是「你玩到現在的一切」。 完整做法看備份與還原 SOP。
⚡ 只想趕快做完?照這五步
- 下載 mvp_modernize.py,從「下載」資料夾拖到桌面(沒拖會出現
can't open file),然後切到桌面
cd ~/Desktop - 先唯讀看你手上那顆是什麼。這一行最後空一格就停住,把
mvp2005.exe拖進終端機視窗再按 Enter
看到「python3 mvp_modernize.py記憶體池 64 MB」就代表這顆可以改;看到「找不到」就是光碟原版,這一項做不到 - 預覽,還是不會動到檔案。本站量到的是 6 個位元組(2 個是記憶體池、4 個是檔頭校驗值;你的數字可能是 3 或 4,因為校驗值有幾格會剛好本來就對)
python3 mvp_modernize.py "<剛才那個路徑>" --pool 128 - 確定了才真的改,會先備份成
mvp2005.exe.modernizebak
python3 mvp_modernize.py "<路徑>" --pool 128 --apply - 用完整的中文語系加上你最大的那座球場進遊戲。還是當就試
--pool 256;開不起來就立刻--restore,不要重複嘗試更大的值
後悔的話,這一行還原:
python3 mvp_modernize.py "〔路徑〕" --restore
Windows 的話:切到桌面改打 cd %USERPROFILE%\Desktop,python3 一律換成 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 | ✅ 可以玩 | — |
兩處各只改一個位元組,現象消失,而且兩個不同的值都成立。 「當機」那一邊還重現了兩次、當在同一個位址。
症狀長什麼樣
- 把中文語系檔(
FEENG.LOC、IGENG.LOC、fonts.big)蓋上去 - 選一座比較大的球場(本站測的是 RFK,8.51 MB)
- 讀取條跑完 → 直接回到桌面。沒有錯誤視窗、沒有當機報告、Windows 事件檢視器也不一定有東西
而且它非常一致:
| 小球場 | 大球場 | |
|---|---|---|
| 英文語系 | ✅ | ✅ |
| 中文語系 | ✅ 進得去 | ❌ 跳出 |
兩個單獨都沒事,加起來才出事。這是整件事最重要的一條線索 —— 它代表卡住的東西不是「球場」也不是「字型」,而是兩者共用的某個東西。
我們走錯的十二條路
這一節不是自我檢討,是這一頁最有用的部分。 每一條錯路都曾經看起來很合理,而其中好幾條是被本站自己的檢查工具 或本站自己的舊紀錄帶偏的。
① 「球場檔太大」(社群傳了二十年的說法)
本站先把那顆 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,001 | 7.999 | stxt774 stxt371 |
| 社群 2023 版 | 5,443,584 | 6.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_DEVICE 與 PRINT_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 吃不吃得下 |
|---|---|---|---|---|
| 英文 | 0x6D | 256 × 128 | 2.00 | ✅ 16 位元 4:4:4:4,直接用 |
| 中文 | 0x79 | 1024 × 512 | 0.50 | ❌ 4 位元,D3D8 沒有這個格式 |
Direct3D 8 沒有 4 位元的貼圖格式,所以中文字型的每一張字圖集 都必須在執行的時候整張展開成 32 位元才能送進顯示卡。 那一份展開後的複本,就是從那顆 64 MB 池裡挖的。
| 語系 | 要展開的像素 | 佔記憶體 | 佔那顆 64 MB 池 |
|---|---|---|---|
| 中文 | 3,538,944 | 13.50 MB | 21.1% |
| 英文 | 96,512 | 0.37 MB | 0.6% |
差 36.7 倍。光是換上中文語系,那顆池子就先被吃掉五分之一, 剩下的空間再也塞不下一座大球場。這就是「兩個加起來才當」的真正原因。
⚠️ 順帶訂正本站自己:像素格式那頁
原本把 0x79 寫成「1×1 佔位圖」(那一頁已經在 2026-08-28 訂正過了,
現在寫的是 4 位元索引色、每像素 0.5 個位元組)。這裡它是一張 1024×512 的 4 位元圖。
那個「1×1」是當初取樣到的樣本剛好長那樣,不是格式的意義。
⚠️ 這一頁量的是哪一份中文字型包
市面上的中文化不只一種,而它們動的槽位不一樣:
| 哪一種 | 字型檔名結尾 | 幾個 | 語系檔 |
|---|---|---|---|
| 社群包(本頁量的) | _en | 12 | FEENG.LOC / IGENG.LOC,蓋掉英文 |
| EA 官方繁體中文版 | _jp | 13 | 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 | ✅ | ✅ | ✅ |
為什麼會這樣:
- 4GB 是檔頭的一個位元 → 任何版本都改得到
- 解析度 是一張資料表 → 任何版本都改得到
- 記憶體池 是一行程式碼 → 光碟原版的程式碼是加密的,找不到,改不了
工具會自己判斷並拒絕,不會亂改。看到 「找不到記憶體池的樣態」就是你手上這顆屬於上面那兩排。
本站不教怎麼處理光碟保護。如果你手上只有原版執行檔, 這一課的記憶體池部分你就是做不到 —— 這是本站做不到的事,不是你的問題。
👉 你要做的事
每一步的指令都給兩行:上面那行是 Windows,下面那行是 Mac。 只跑你自己那一行。不會開終端機的話,先看 新手基本功第 1 課。
mvp_modernize.py(零相依,同一支也管 4GB 與解析度)
cd %USERPROFILE%\Desktop
cd ~/Desktop
瀏覽器多半會直接放進「下載」資料夾,所以按完之後還有一步:
1打開檔案總管(Mac 用 Finder),左邊點「下載」
2找到 mvp_modernize.py
3把它拖到桌面
沒做這一步的話,後面執行會出現 can't open file ——
那不是你打錯,是檔案還在「下載」裡。
會印出架構、記憶體上限、記憶體池大小、以及解析度表的八筆內容。
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」就代表這顆可以改。
看到「找不到」就是你手上這顆是光碟原版,這一項做不到(原因見上一節)。
它會把要改的位元組位置列出來給你看(畫面上最多列前 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。
會先備份成 mvp2005.exe.modernizebak,
寫完再讀回來確認「改動的位元組剛好是預期那幾個,其他一個都沒動」。
python mvp_modernize.py "<路徑>" --pool 128 --apply
python3 mvp_modernize.py "<路徑>" --pool 128 --apply
三個參數可以同時給,它會一次算完再一起寫:
python mvp_modernize.py "<路徑>" --4gb --resolution 1920x1080 --pool 128 --apply
python3 mvp_modernize.py "<路徑>" --4gb --resolution 1920x1080 --pool 128 --apply
但第一次建議只做 --pool。三個一起改,
萬一出事你分不出是哪一個造成的。
這一步不能跳。本站驗得到「檔案是對的」,驗不到「遊戲讀得動」。
如果你平常會多開幾顆執行檔(mvp2005 - 4GB.exe 之類),
記得你實際雙擊的是哪一顆就改哪一顆。改了 A 卻跑 B 是最常見的白工。
✅ 成功標準
- 再跑一次第 2 步的唯讀檢查,「記憶體池」顯示 128 MB
- 中文語系 + 大球場進得去比賽
- 中文完整,沒有缺字(因為完全沒有動到字型)
❌ 出錯處理
| 看到什麼 | 怎麼做 |
|---|---|
| 「找不到記憶體池的樣態」 | 你這顆是光碟原版,程式碼加密,這一項做不到 |
| 「開頭不是 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(遮蔽版)
還沒解決的部分
這一頁的結論是靠對照實驗成立的(改一個變數、現象消失、兩個值都成立), 不是靠抓到現行犯。以下是本站沒有量到的:
- 沒有直接看到那行
OUT OF MEMORY。 它走OutputDebugStringA,而本站在 Wine 底下沒有攔成功。 在真 Windows 上用 DebugView 應該看得到。 - 不知道是「總量不夠」還是「最大的連續空塊不夠」。 記憶體碎片化也會造成同樣的症狀,而把池子調大兩種都會改善,所以這個實驗分辨不出來。
- 不知道 128 MB 是不是最省的值。只知道 128 與 512 都能玩、64 不行。 真正的門檻在 64 與 128 之間某處。
- 13.50 MB 是算出來的,不是量到的。 它等於「中文那些 4 位元字圖集的總像素 × 4 位元組」。 本站從字型檔量、另一條線從執行檔的程式碼推,兩邊在小數點後兩位對上, 但兩邊都沒有量到遊戲執行時真正的用量。
本頁用到的其他工具
- mvp_font_swap.py ——
一個一個換字型做二分測試,另有
--dedupe把內容相同的字型合併成共用一份 (字型一個都不少;至於檔案少多少要看是哪一份fonts.big:本站量到中文那 3 份少 28.8%, 2009 台灣模組那 2 份英文的少 52.8%,因為後者還會一起丟掉目錄指不到的那一大段)。
想先確認這一支在你的機器上是好的,跑python3 mvp_font_swap.py --selftest—— 它不需要遊戲資料夾,也不碰任何遊戲檔,自己造一份最小的封裝檔把每一道把關正反各測一次 - mvp_fix_loc.py ——
修中文語系檔裡格式符數量對不上的字串(168 條)。
實測:修完 0 條殘留、中文字元一個都沒被改動。
同樣可以先跑python3 mvp_fix_loc.py --selftest確認這一支在你的機器上是好的 (不需要遊戲資料夾,也不碰任何遊戲檔) - mvp_wine_log.sh ——
Mac 專用。原本是要在 Wine 底下攔遊戲的除錯訊息,
但這條路本站沒走通(見上面第 ⑨ 節:
debugstr這個頻道打在空氣上, 11 次實跑一行都沒收到)。它現在的用處只剩收+seh的當機現場 —— 本站最後沒有靠它拿到結論,但它抓到了當機位址。
要貼到網路上或傳給別人,請用它另外產的那一份mvp_debug_<...>_可回報.log:家目錄、帳號名稱、遊戲資料夾都換成代號, 原始那一份留在自己電腦。第二個參數(執行檔)只能是檔名,不可以帶路徑。 另外,遊戲資料夾裡懶人包那一層的.wine_path設定檔指到的程式 只有落在標準 Wine 安裝位置才會被執行,別人做的懶人包夾帶的一律忽略。 想確認腳本自己的守門有沒有壞掉,跑bash mvp_wine_log.sh --selftest—— 它不會啟動遊戲,也不會碰遊戲資料夾。
Windows 上要看同一批訊息,用微軟自家的 DebugView (Sysinternals,免安裝):以系統管理員身分執行,把Capture Win32與Capture Global Win32兩項打勾, 再開遊戲。遊戲會印出SKU Mem Initialized.、total free mem: %d,記憶體不夠時會印MemoryXAlloc(...) OUT OF MEMORY。 未驗 這一段還沒在 Windows 上實測, 以上是從執行檔的程式碼推出來的
📌 重點整理
- 一句話 卡了二十年的不是球場大小,是遊戲開機時寫死跟系統要的一整塊 64 MB 記憶體池(
007736F0 push 0x4000000),球場、字型、所有.big都從那裡面挖,把04改成08變 128 MB 就好了,中文一個字都不用砍。 - 結論是靠對照實驗成立的 64 MB(原廠)兩次都當,而且當在同一個位址
0x007D8423;128 MB 可以玩、512 MB 也可以玩。兩個地方各改一個位元組、現象消失、兩個值都成立。當機那一段本身,是印完「記憶體不夠」之後沒有jmp跳走,直接拿 NULL 去填值,所以玩家看到的不是錯誤訊息,是直接回到桌面。 - 中文為什麼特別貴:13.50 MB vs 0.37 MB 中文字型的字圖集是 1024 × 512 的 4 位元圖(格式
0x79),而 Direct3D 8 沒有 4 位元的貼圖格式,每一張都要在執行的時候整張展開成 32 位元。展開後吃掉那顆池子的 21.1%,英文只有 0.37 MB / 0.6%,差 36.7 倍。這就是「中文單獨沒事、大球場單獨沒事、加起來才當」的真正原因。 - 4GB 補丁救不了這個當機 4GB 是 PE 檔頭的一個位元,管的是程式最多能用到多少位址空間;記憶體池是程式碼裡的一個
push,管的是遊戲實際跟系統要了多大一塊。塞不下是因為櫃子小,把房間從 2 坪擴到 4 坪,櫃子還是那個櫃子。兩件事都要做,但互不取代。 - 不是每顆執行檔都改得動 記憶體池是一行程式碼,光碟原版的程式碼是加密的(熵 7.999、帶
stxt774stxt371保護區段),找不到就改不了;社群 2023 版熵 6.575 是明碼的,改得到。4GB(檔頭的一個位元)與解析度(一張資料表)則任何版本都改得到。工具會自己判斷並拒絕,不會亂改。 - 最容易踩的雷:改了 A 卻跑 B 如果你平常會多開幾顆執行檔(
mvp2005 - 4GB.exe之類),你實際雙擊的是哪一顆就改哪一顆,這是最常見的白工。另一個是腳本還留在「下載」資料夾,那會出現can't open file,不是你打錯。 - 安全網 不加
--apply就只是預覽,會把要改的每一個位元組位置列出來(本站量到 6 個:2 個記憶體池 + 4 個檔頭校驗值;換一顆執行檔總數會變,本站量到 3 到 6 都有,記憶體池那 2 個一定在);--apply會先備份成.modernizebak,寫完再讀回來確認「改動的位元組剛好是預期那幾個,其他一個都沒動」;一行指令可以還原(--restore);而且完全不動字型,所以中文不會缺字。改完開不起來就立刻還原並回報,不要重複嘗試更大的值。 - 本站沒驗的 沒有直接看到那行
OUT OF MEMORY,它走OutputDebugStringA,本站在 Wine 底下沒有攔成功(真 Windows 上用 DebugView 應該看得到,但還沒實測);分不出是「總量不夠」還是「最大的連續空塊不夠」,因為池子調大兩種都會改善;不知道 128 MB 是不是最省的值,只知道 64 不行、128 與 512 可以,門檻在那中間某處;13.50 MB 是算出來的,不是量到的,一條從字型檔量、一條從執行檔的程式碼推,兩邊在小數點後兩位對上,但都不是遊戲執行時的真實用量。
這支腳本在做什麼
這一課附了三支腳本。其中會動你檔案的是兩支(mvp_font_swap.py 與 mvp_fix_loc.py),流程是同一個形狀:先讀、再比、再預覽,你點頭了才寫。
這兩支的修改都是預設只看不改,加上 --apply 才會動到你的檔案,而且動之前一定先備份。
寫完再讀回來對一次三條 --apply 都有:mvp_fix_loc.py --fix --apply、mvp_font_swap.py --swap --apply 與 mvp_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.LOC 與 data/FEENG.LOC。
它把中文版與英文版照字串編號逐條對帳,挑出「中文要的參數比英文多」的那些
(本站在測試機上量到 168 條),只把多出來的格式符拿掉,
中文字一個都不動。
mvp_font_swap.py(換字型)
| 哪一段 | 做什麼 | 為什麼要有它 |
|---|---|---|
paths() |
算出兩條路徑:會被改的 data/fonts/fonts.big,以及拿來換過去的來源檔。來源檔照一張清單找第一個存在的,fonts 與 Fonts 兩種大小寫都試。 |
英文語系排在中文語系前面,因為英文版是「不會當」的那一邊,換過去才算是在減少變數。大小寫要兩種都試,是因為中文化模組是在 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 這種替換符,由遊戲的字串引擎處理,不會餵給 printf。2 與 6 不在轉換字元那張表裡。⚠️ 但旗標那一類裡含一個半形空白,所以「% 加數字、一個空白、再接一個剛好是轉換字元的英文字母」會被連成一段吃掉:%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 與 %4d,IGENG.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 --apply,frk12_en/twc14_en/twcnb_en 三個各 183,942 bytes 的字型合併成一份、檔案變成 911,368 bytes、解開後總量 2,726,912 沒變、字型數量 12 → 12,而且輸出最後多了「複驗:12 個項目全部逐位元組相同 ✅」那一行 —— 也就是本站原本寫的「這條路沒有複驗,修好之後也還是沒有」同樣已經作廢。--swap、--restore、不加旗標那三條本來就正常。
2026-09-05 還多了兩件事,兩支都有:中途按 Ctrl-C 會明確告訴你遊戲檔有沒有被動到 ——
程式記著 os.replace 到底做過幾次,所以它講的是「什麼都沒動」還是「已經改了,用 --restore」,
離開碼一律 130;mvp_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.
# ─────────────────────────────────────────────────────────