教學 › 換掉開機畫面

換掉開機畫面

遊戲一啟動看到的那張大圖、標題頁、讀取中那張,本站三張都量出來了, 三張也都匯得出來,但目前只有標題頁與讀取畫面匯得回去: 開機大圖那一張會被擋,實測數字與原因寫在開機大圖為什麼匯不回去。 這一課寫的不只是怎麼換,還有本站是怎麼找到它們的: 因為現成的社群說明書在這件事上跟本站這台機器對不上,而查出真正位置的過程本身就是一堂課。

會改你的檔案 會動到遊戲檔的只有 --import … --apply 與 --restore 兩條路。 --restore 不吃 --apply,打下去就會用備份還原封裝檔(本站實測)—— 還原本身是先寫暫存檔、驗完內容跟備份逐位元組相同才換上,不會讓封裝檔停在半截。

難度

★★☆ 要會用修圖軟體

時間

約 15 分鐘

要會什麼

會存 PNG

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

把整個遊戲資料夾複製一份到別的地方, 包括裡面存放紀錄的資料夾。紀錄也可能放在遊戲資料夾外面: 「文件」裡的 Documents\MVP Baseball 2005,那一個要另外複製。

怎麼整包複製、紀錄(王朝、名單、設定)的兩個位置各在哪、找不到怎麼找: 看備份與還原 SOP 的「整包複製」。

⚡ 只想趕快把畫面換掉?照這五步(要先裝好 Python,沒裝的看第 0 課操作五)

先說一句:title(標題頁)與 loading(讀取畫面)換得進去, splash(開機大圖)目前只匯得出來、匯不回去,所以下面拿 title 當例子。 實測數字看開機大圖為什麼匯不回去。

  1. 先下載 mvp_swap_screen.py,它會先放在「下載」資料夾,請搬進 MVP_Tools(第 0 課操作六)。再打開檔案總管進到 MVP_Tools,點一下最上面的網址列,把字刪掉改打 cmd 按 Enter,後面的指令都在開出來的這個視窗打(Mac 開終端機先打 cd ~/MVP_Tools)。
    先看三個畫面在你這份裡各是什麼規格,數字以印出來的為準
    python -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --info
  2. 把現在那張匯出來當範本,代號可以是 splash、title、loading,三個都匯得出來。輸出檔名一律用 .png——--export 這條路不做備份。沒寫路徑的 old.png 會存在命令視窗所在的資料夾,改好的 mine.png 也放在那裡
    python -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --export title old.png
  3. 用繪圖軟體在匯出的那張上面直接改,存成 PNG。尺寸必須完全相同,不一樣就直接拒絕寫入,腳本不會幫你縮放。
  4. 先預覽,這一步不會動到任何檔案
    python -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --import title mine.png
  5. 確認沒問題,同一行後面加 --apply。第一次寫入時會自動備份。

⚠️ 開機大圖(splash)目前只出得來、進不去。 本站量得到的五份安裝(剛安裝好的英文原版、剛安裝好的中文原版、本站測試機那份 2023 模組版, 以及歷史資料裡的 PK 版與 NEW TC_PATCH123 版)都會在第 4 步被擋下,訊息是 「新像素有 131072 個位元組,原本是 131088」(512 × 512 那四份是這個數字, 1024 × 1024 那份是 524288 對 524304,差的都是 16)。 現在真的換得動的是 title(標題頁)與 loading(讀取畫面)這兩張, 而且那兩張是未壓縮的,匯出再匯回去逐位元組相同。細節見📌 重點整理。

拿到腳本先跑這一行,確認你下載到的是完整的一份, 這一步不需要遊戲資料夾:

python -I mvp_swap_screen.py --selftest

後悔的話,這一行還原:

python -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --restore

速解裡的 python 是 Windows 寫法,Mac / Linux 換成 python3(往下讀的正文已經是 python3 版)。遊戲開不起來就改用 --restore,三個封裝檔裡備份過得了檢查的就會還原,壞掉的那份會單獨列出來告訴你下一步。 想知道這三個畫面是怎麼找出來的,往下讀;只想複習,跳到📌 重點整理。

照做卡住了?直接跳到❌ 出錯處理(可以把畫面上印出來的那一行貼進那裡的搜尋框)。

三個畫面在哪

全部是現場量的。但開機大圖的尺寸不是固定的,要看你手上這一份是哪一份: 下表那個 1024 × 1024 量自本站測試機那份 splash.big, 而剛安裝好的原版只有 512 × 512。另外兩張兩邊量起來一樣。 動手前先跑一次 --info,以它印出來的為準,不要照抄下表的數字。

代號封裝檔裡面那一項圖名格式尺寸
splashfrontend/splash.big splash.fshfirsDXT1(0x60) 1024 × 1024
(剛安裝好的原版是 512 × 512)
titlefrontend/sushared.big license.fshlice32 位元(0x7D) 1024 × 512
loadingfrontend/suonly.big memcard.fshmemc32 位元(0x7D) 512 × 512

firs 應該是 first,開機第一張。三張都被 QFS 壓縮過, 所以直接用文字編輯器打開只會看到亂碼。

※ 只有開機大圖的尺寸會因為你這份被誰疊過而不同。本站量得到的五份安裝裡, 剛安裝好的英文原版、剛安裝好的中文原版,以及歷史資料裡的 PK 版與 NEW TC_PATCH123 版 都是 512 × 512,只有本站測試機那份 2023 模組版是 1024 × 1024; 標題頁 1024 × 512、讀取畫面 512 × 512 這兩個數字則是五份完全相同。 請以你自己跑 --info 印出來的為準。

⭐ 這張表是怎麼查出來的

值得寫下來,因為它示範了一件事:現成的說明書不一定對得上你手上這一份,而且對不上的時候,它看起來還是很有說服力。

起點:一份 2007 年的社群工具說明書

那份說明寫:開機畫面是遊戲根目錄底下一個叫 00000000.256 的檔, 做一張 640×480 的圖放進去就好。它甚至附了當年論壇討論串的網址。

但在本站測的這台機器上:整個遊戲目錄一個 .256 都沒有。 執行檔裡也搜不到那個字串。

(2026-09-05 訂正:本站原本把這份說明書寫成「2008 年」,那是錯的。 它就是工具清冊裡 initialsplashmaker 那一支所附的說明書,署名 compmaniac; 壓縮檔內記的檔案時間是 2007-01-13,同一包的執行檔是 2007-01-11 與 2007-01-13。 本站另一頁工具是誰做的把同一組 11 個壓縮檔整批量過,年份也是 2007。說明書內文自己沒有寫年份, 所以這裡寫的是那份檔案本身的時間。)

第一條線索:執行檔自己的符號

拿執行檔去撈可讀的字串,撈到這些:

Bsplash.%s
FSPLASH   FSPLASHB   FSPLASHCS
LEGAL     LEGALGRP
LOADING   CUSTOMLOADING
DrawLoadingProgress: %d, %.3f @ %.3f seconds

這些名字的長相,跟本站早就解開的 版面檔裡的元素名稱是同一種。 所以開機畫面走的是版面檔系統,不是散落在根目錄的檔案。

第二條線索:版面檔指名道姓

掃過去,在 title.big 裡找到 fes_title.fel,內容只有 31 行:

SC:TITLE,1,0,0,0,640,480,…        640×480 的畫面
 LF:twc14_en.ffn
 LS:license                       載入名叫 license 的圖集
 LS:memcard
 SH:CLICKME,1,0,2,2,640,480,…,lice,lice,…   用 license 那個圖集
 SH:LOADING,1,0,-2,0,643,480,…,memc,memc,…  用 memcard 那個圖集
 SH:LEGAL,0,0,0,0,640,480,…,lice,lice,…    授權畫面,開關是 0

然後掃全部 384 個封裝檔的目錄表,找到 sushared.big 裡有 license.fsh、 suonly.big 裡有 memcard.fsh。 解開之後看圖集裡那張圖的名字——lice 和 memc。

為什麼這算「量到的」而不是「猜的」

本站把證據分三級,最弱的一級是「數量剛好對得上」——那是巧合的溫床。

這次拿到的是最強的一級:版面檔那一行寫的四個字母 lice, 跟圖集目錄裡那張圖的名字 lice,是同一個字串。 不是「都有三個」這種數量吻合,是名字對上名字。

⭐ 順帶量到一件事:LEGAL 那一行的顯示開關本來就是 0—— 這台機器的授權畫面已經被關掉了。

👉 你要做的事

先下載 mvp_swap_screen.py。指令要在放腳本的那個資料夾打。瀏覽器下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列(整條會反白),把字刪掉改打 cmd 按 Enter —— 開出來的命令提示字元已經停在 MVP_Tools 裡,後面的指令都在這個視窗打(第 0 課操作一的「資料夾法」)。Mac:打開終端機,先打 cd ~/MVP_Tools 按 Enter。

1

先看三個畫面各是什麼規格。

Windows:

python -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --info

Mac / Linux:

python3 -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --info

它會把上面那張表印給你,而且是從你自己的檔案量的—— 如果你這份被前人改過,數字可能不一樣,以印出來的為準。

2

把現在那張匯出來當範本。

Windows:

python -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --export splash old.png

Mac / Linux:

python3 -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --export splash old.png

代號可以是 splash、title、loading,三個都匯得出來。 但下一步要匯回去的話,目前只有 title 與 loading 走得通, 見開機大圖為什麼匯不回去。

old.png 會存在命令視窗所在的資料夾(照上面從 MVP_Tools 開的話,就在 MVP_Tools 裡);改好存成 mine.png,也放在同一個資料夾。

3

用繪圖軟體改,存成 PNG。

⚠ 尺寸必須完全相同

不一樣就直接拒絕寫入,腳本不會幫你縮放。 這是刻意的:幫你猜只會把圖弄糊,而且你不會知道發生了什麼。

最穩的做法是在匯出的那張上面直接改。

4

先預覽。這一步不會動到任何檔案。

Windows:

python -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --import title mine.png

Mac / Linux:

python3 -I mvp_swap_screen.py "〔你的遊戲資料夾〕" --import title mine.png

確認沒問題之後,同一行後面加 --apply。第一次寫入時會自動備份。

⚠ 這一步換 splash 一定會被擋下來

訊息是「新像素有 131072 個位元組,原本是 131088」。 不是你的圖有問題:本站量得到的五份安裝, splash.fsh 的圖片記錄宣告的長度都比「記錄檔頭 16 + 像素」 多出 16 個位元組(那 16 個都是零),長度守門員因此拒絕寫入。 把代號換成 title 或 loading 就寫得進去, 而且那兩張是未壓縮的 32 位元圖,匯出再匯回去逐位元組相同。

代號寫 title 或 loading 都走得通。 寫 splash 一定會被擋,下一段講為什麼。

⚠️ 開機大圖(splash)目前匯不回去

不是你的圖有問題,是這一張現在寫不進去。 連 --export 出來的那張原封不動匯回去也會被擋。 本站手上六份 data 資料夾,六份都一樣:

量在哪一份上那一份的 splash 尺寸跑 --import splash 會說
本站測試機那份(被前人疊過的安裝),以及測試機自己留的那份備份資料夾 1024 × 1024 新像素有 524288 個位元組,原本是 524304
剛安裝好的原版英文版、剛安裝好的原版繁體中文版、PK 版、另一份社群繁體中文化版 512 × 512 新像素有 131072 個位元組,原本是 131088

差的永遠是 16 個位元組。splash.fsh 裡那一筆記錄自己宣告的長度, 比「16 個位元組的記錄檔頭 + 寬 × 高 ÷ 2 的像素」多 16 個位元組; 而腳本壓出來的 DXT1 固定就是 寬 × 高 ÷ 2 個位元組,長度對不起來就被擋。

那多出來的 16 個位元組印出來看全是 0,本站沒查出它們是什麼。 但宣告的長度確實指到下一筆附加記錄的開頭(緊接著那一筆自己寫著 EAGL64 metal bin attachment for runtime texture management), 所以不是本站把邊界算錯。

⚠️ 這不是「DXT1 一律寫不回去」。同一套編碼器在 models.big 的臉皮上長度是對的:本站測試機那份 models.big 裡 讀得出來的 889 張 DXT1 臉皮,889 張都是「宣告長度 = 16 + 寬 × 高 ÷ 2」,0 個例外。 卡住的是 splash.fsh 這一筆記錄的長度欄位,不是 DXT1 這個格式。

✅ 本站怎麼驗這支腳本

在一份測試用的遊戲副本上做的,四項。驗的那一張是 title (splash 寫不進去,見上面那張卡):

驗什麼結果
預覽真的不動檔案✅ 預覽前後封裝檔的 md5 完全相同
寫入是附加不是重打包✅ 檔案只增加 2,116,028 bytes,舊資料一個位元組沒動
改動真的進去了✅ 把左上角 64×64 塗成純紅,寫入後重新匯出,那一塊是純紅
改動範圍精確 ✅ 先算「改 64×64 = 4,096 個像素 → 相同率應為 99.22%」, 再去量,量到 99.22%

最後一項是刻意設計的:先預測、再量測。 只驗「看起來有變」的話,多改或少改了都看不出來。

關於檔案會變大

本站的封裝方式是不做壓縮比對的保守版, 所以寫回去的那一項會比原本大(實測 1.2 MB → 2.1 MB)。 換來的是:解出來的資料逐位元組跟原本相同,不會因為壓縮實作差異而出錯。

加上附加模式本來就會讓檔案長大。這是刻意的取捨: 磁碟空間換正確性。寫入時還會暫時多放一份跟封裝檔一樣大的工作複本(第一次還要再加一份備份),空間不夠就在動任何檔案之前停下來,結束碼是 2。介意的話,把備份留著、需要時 --restore。

❌ 出錯處理

症狀怎麼辦
can't open file '…mvp_*.py' 命令視窗跟腳本不在同一個資料夾。 打開放著那個 .py 的資料夾(照第 0 課操作六搬過的話就是 MVP_Tools),在網址列打 cmd 按 Enter,用新開的視窗再跑一次(Mac 用 cd 切過去,例如 cd ~/MVP_Tools)。 還是一樣的話,是檔名對不上:瀏覽器可能在檔名後面加了 (1),把它改成跟指令裡一模一樣
說尺寸不對用 --export 出來的那張當底圖,不要自己開新檔。 換的是 splash 的話,換底圖也沒用:它現在匯不回去, 見開機大圖為什麼匯不回去
換完圖變模糊換得回去的 title 與 loading 是 32 位元未壓縮,不會糊。 會糊的是 DXT1 有損壓縮的 splash,而它目前匯不回去
說「不是 SHPI 檔」你這份的封裝檔可能已經被別的工具動過。 先跑 --info 看它認不認得
說「讀不到你的 PNG」檔名打錯,或路徑裡有空白/中文卻沒用引號整個包起來。 遊戲檔沒有被動到
說 PNG 的「影像資料解不開」那張 PNG 是半截的(下載沒完成、複製到一半被中斷)。 用修圖軟體重新存一次再匯入
說「檔頭大小欄位跟實際檔案大小對不上」這個封裝檔已經被寫壞了,本工具不敢在上面接東西。 用本工具改過就跑 --restore;沒有本工具的備份,就從你自己那份完整備份複製一個回來。 這一道擋在備份之前,壞掉的檔不會被存成 .screenbak
--export 說目標「已經存在,而且它不是 PNG」輸出檔名打到遊戲檔或別的檔了。 --export 這條路不做備份,所以它擋著不覆蓋。換一個 .png 的檔名
遊戲開不起來--restore。三個封裝檔裡,備份過得了檢查的就還原, 壞掉的那份會單獨列出來說下一步;只要有任何一份沒還原成功,結束碼就是 1
說「寫入途中出事了」,而且沒辦法確認有沒有已經換成新的換名那一刻碟讀不到了(外接碟被拔掉), 或複本、遊戲檔被防毒軟體之類的程式動過,事後分不出新舊。先別急著 --restore (還原會退回最早的那份備份,先前改過的會一起退掉):碟接好之後,用 --export 把那個畫面匯出來看一眼, 是你的新圖就是換成功了;還是原來那張就直接再跑一次同一行;打不開或看起來是壞的,才跑 --restore
說「還沒動手就量不到…的大小」空間預檢那一刻封裝檔或它所在的碟讀不到。 訊息會照實說檔案還在不在、備份在不在(問不到就說問不到),以及遊戲檔與備份都沒有被碰過。 碟接好、確認檔案還在,再跑一次同一行
跑到一半按了 Ctrl-C它會直接告訴你遊戲檔到底有沒有被動到: 沒動到就說「遊戲檔一個位元組都沒有動到」,再跑一次就好;動到了就把那些檔名列出來, 順便把 --restore 那一行印給你;碟讀不到、分不出新舊時就說「沒辦法確認」,改教你用 --export 看一眼。 兩種情況的結束碼都是 130,不是 0—— 照著跑批次的人只看結束碼,0 會被當成「做完了」

本站沒有驗的事

📌 重點整理

這支腳本在做什麼

流程是一條直線:在封裝檔的目錄裡找到那一張圖 → 解壓 → 從圖集裡量出格式與尺寸 → 攤平成一般的像素 → 存成 PNG; 換圖就是把後半段反過來走一次,最後接到封裝檔的最後面。 三個動作(--info、--export、--import) 共用同一段前半,所以你在預覽看到的規格,就是真的寫入時用的那一份。 整支零相依,PNG 與 DXT1 的讀寫都是自己寫的,你不必先裝 Pillow 才能改圖。

哪一段做什麼為什麼要有它
SCREENS / locate() 查出三個畫面各住在哪個封裝檔、裡面那一項叫什麼, 再到目錄裡把它找出來,順便記下「這一項在目錄裡的位置」。 那張對照表是量出來的,不是抄說明書(上面 「⭐ 這張表是怎麼查出來的」那一節有經過)。記下目錄位置, 是為了寫入時只改那 8 個位元組,不必重寫整份目錄。
big_entries() 讀封裝檔(BIGF)的目錄, 回傳每一項的名稱、目錄位置、資料位移與長度。 目錄項目沒有固定長度:4 個位元組的位移、 4 個位元組的長度,接一個以 NUL 結尾的檔名。所以只能一項一項往前走, 不能用乘法直接跳到第 n 項。
qfs_decompress() 開頭兩個位元組是 10 FB 就解壓, 不是就原樣回傳。 三張圖在封裝檔裡都是壓縮的, 直接用文字編輯器打開只會看到亂碼。長度欄位是 big-endian, 跟後面圖集內部的 little-endian 相反,看錯就整個解不出來。
fsh_first_image() 從圖集(SHPI)裡量出格式代號、寬、高, 以及像素在檔案裡的起點與終點。 這四個數字決定後面每一步。 尤其是像素終點:它抓得準不準, 直接決定「新舊像素長度一不一樣」那道防線攔不攔得住。
to_rgba() / dxt1_decode() 把檔案裡的像素攤平成統一的 RGBA。 兩種格式的排法完全不同:DXT1 是每 4×4 一塊 的壓縮格式,0x7D 則是未壓縮但順序為 BGRA。 統一成一種排法,後面只要認得一種。BGRA 看錯不會壞檔, 只會紅藍對調,而你多半會以為是自己那張圖有問題。
png_write() / png_read() 自己寫、自己讀 PNG。讀得進 8 位元的五種色彩型別: 灰階 / RGB / 索引色 / 灰階加透明度 / RGBA。 只用 Python 內建的 zlib, 所以你不必先裝任何套件。那五種是 PNG 規格裡的色彩型別 0、2、3、4、6; 本站拿這五種各配上五種預測濾波,做成 25 張 4×4 的 PNG, 餵給這一課的 mvp_swap_screen.py 讀, 25 張讀出來的像素都跟原圖逐位元組相同。 代價是不支援交錯式與 16 位元色深, 遇到就明白擋下來並說原因,不會亂猜。
from_rgba() / dxt1_encode() 把 RGBA 編回原本那一種格式。 DXT1 的部分是一塊 4×4 窮舉所有端點配對,取誤差最小的。 格式代號是從檔案讀出來的,不是使用者選的: 換格式會連動到記錄裡的長度欄位與後面每一張圖的位移。 窮舉是因為一塊只有 16 個像素,比任何近似法都省事而且不會猜錯; 誤差為 0 就直接收工,那代表這一塊原封不動編回去了。
fsh_replace_pixels() 只換掉像素那一段,前後的位元組原封不動。 新舊長度不同就拒絕。 這是整支最重要的一道防線。圖集記錄裡的長度欄位、 後面每一張圖的位移,全部是照原本的長度算出來的: 只要差一個位元組,後面那些圖就整批對不上。寧可拒絕,不要默默寫壞。
qfs_compress_literal() 把改好的圖集壓回合法的 QFS, 但不做字串比對,純粹逐段照抄。 換來的是速度,以及「不可能壓錯」。 代價是壓出來比 EA 原本的大,不過我們本來就是接到檔尾, 檔案本來就會長大,這裡多的那一點不影響結果。
append_entry() 在工作複本上把新資料接到檔案最後面, 只改目錄裡那 8 個位元組與檔頭的 4 個位元組;複本讀回來驗過,才換上遊戲檔。 本站對封裝檔的鐵律:不重新打包。 你不知道手上這份被前人疊過什麼。舊資料原地不動,所以就算新資料是壞的,舊的也還在檔案裡; 寫的是複本,寫到一半磁碟滿也不會留下半截的遊戲檔(代價是動手時多佔一份跟封裝檔一樣大的空間)。
_atomic_copy() / _do_copy() / _restore_from_backup() 備份與還原。兩邊都寫在目的檔同一個資料夾裡、 一個隨機命名的暫存檔(tempfile.mkstemp), 整份寫完、fsync 落磁碟、跟來源逐位元組比對過, 才用 os.replace 換上;還原之前先驗備份的檔頭。 遊戲檔或備份檔只要是符號連結就直接拒絕。 備份寫到一半被中斷(磁碟滿、外接碟拔掉、 按了 Ctrl-C)會留下半截檔,而半截的備份會在你最需要它的時候 把好檔蓋掉。所以備份要嘛完整、要嘛不存在; 還原前也要先問它「你自己說你有多大」。 「還原」這個動作本身以前也是危險的:它是直接複製覆蓋, 而複製的第一個動作就是把遊戲檔截成 0——中途斷掉, 就會在你最需要它的時候把遊戲檔弄壞。現在還原途中被中斷, 遊戲檔只有兩種樣子:還是改過的樣子,或完整還原好了,沒有第三種。 暫存檔的名字也不可以讓人猜得到:猜得到就有人能先放一條 指到別處的符號連結佔住那個名字,寫下去會穿過去改到資料夾外面的檔。

這一支的 --selftest 只驗檔案安全那幾道守門員 (備份、還原、符號連結、輸出路徑、匯入走工作複本),110 項, 不需要遊戲資料夾就跑得動。那幾道也不是每一道都配到餌: 本站把腳本裡條件成立就讓它停下來的守門逐一拆掉再跑自我測試(2026-09-24 那一版的量測), 檔案安全那一類 15 道裡會變紅的是 6 道,另外 9 道拆掉照樣全綠 (例如備份寫完讀回來對不上、還原的暫存檔長度對不上);2026-10-29 新加的匯入工作複本那一組另外量過,用 17 種拆法逐一拆掉,17 種全部變紅;2026-10-30 補的那一批(換名出事後分三種、量不到大小、符號連結先於空間)又用 26 種拆法逐一拆掉,26 種全部變紅;同一天再補的還原那一批(還原的換名也先登記、出事才問磁碟)用 12 種拆法逐一拆掉,12 種全部變紅。DXT1、QFS、SHPI 那些格式細節它驗不到, 那幾項要有真的遊戲檔才驗得動, 本站怎麼驗的寫在上面 「✅ 本站怎麼驗這支腳本」那一節。做不到的事也全部寫在腳本開頭: 只認 0x60 與 0x7D 兩種格式、不縮放、不轉格式、 也不改圖在畫面上的位置與大小(那寫在版面檔 .fel 裡,是另一課的事)。

完整原始碼

下面這支會改你的檔案。本站的原則是: 會動你檔案的東西,你要能在跑之前先讀完它。 跟你按下載拿到的一個字都沒有不同(本站有自動檢查在守這件事)。

下載 mvp_swap_screen.py

展開 / 收合完整原始碼(3056 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_swap_screen.py —— 換掉遊戲的開機畫面 / 標題畫面 / 讀取畫面

    看有哪些畫面    python3 -I mvp_swap_screen.py "<遊戲資料夾>" --info
    匯出成 PNG      python3 -I mvp_swap_screen.py "<遊戲資料夾>" --export splash out.png
    換成你的圖      python3 -I mvp_swap_screen.py "<遊戲資料夾>" --import title mine.png
    真的寫入        (上面那行後面加 --apply)
    還原            python3 -I mvp_swap_screen.py "<遊戲資料夾>" --restore
    自我測試        python3 -I mvp_swap_screen.py --selftest

會動到遊戲檔的只有兩條路:「--import --apply」與「--restore」。
⚠️ --restore 不吃 --apply,備份過了檢查就會把 .screenbak 換回封裝檔(本站實測)。
--export 沒加 --apply 也會寫檔,但寫的是你指定的那個 PNG,遊戲檔一個位元組都不動。
   ⚠️ 這件事 2026-09-05 起有守門員把關:輸出路徑上已經有一個「不是 PNG」的檔
   就直接拒絕,不覆蓋。在那之前,把輸出檔名打成遊戲檔會當場把它蓋成 PNG。

⚠️ 這三個畫面各自在不同的封裝檔裡,尺寸與格式也不同(--info 會告訴你)。
   匯入的 PNG **尺寸必須完全相同**,不同就拒絕寫入,不會幫你縮放。

⚠️ 上面匯入的例子刻意寫 title 不寫 splash:**splash 這一張匯得出來、匯不回去**,
   連 --export 出來的那張原封不動匯回去也會被擋。實測數字與原因寫在下面
   「做不到的事」。title 與 loading 兩張匯得回去。

⚠️ 寫入採「附加」模式:舊資料留在原地,只改目錄表指到新位置。
   這是本站對 .big 的一貫紀律 —— 你不知道手上這份被前人疊過什麼。

它做什麼(一句話)
    把三張大圖的像素換成你自己的 PNG。不動版面檔、不動遊戲程式,
    只換封裝檔裡那一張圖的像素而已。

輸入
    · 遊戲資料夾(裡面要看得到 data 這個子資料夾)
    · 匯入時再加一張 PNG。8 位元色深的灰階 / RGB / 索引色 / 灰階+透明 / RGBA 都讀得進來;
      交錯式(interlaced)與 16 位元色深會被擋下來,訊息會告訴你原因。

輸出
    --info     把三個畫面的格式與尺寸印出來,不產生任何檔案
    --export   產生一張 32 位元 RGBA 的 PNG
    --import   不加 --apply 只印預覽;加了才會動到封裝檔

安全網在哪
    · 預設唯讀,但有一個例外:--import 沒有 --apply 一個位元組都不會寫進去,
      而 --restore 不吃 --apply,備份過了下面那幾道檢查就會還原
    · 第一次寫入前自動備份成 <封裝檔>.screenbak,而且備份是原子的:
      寫的是一個隨機命名的暫存檔,整份寫完 + fsync + 逐位元組比對過才改名,
      中途被中斷不會留下半截備份
    · **還原也是原子的**(2026-09-05 改):先在同一個資料夾寫一個暫存檔,
      驗完內容跟備份逐位元組相同,才用 os.replace 換上。所以還原途中被中斷時,
      遊戲檔只有兩種樣子 ——「還是改過的樣子」或「完整還原好了」,沒有第三種。
      在這之前是直接複製覆蓋,而複製的第一個動作就是把遊戲檔截成 0
    · 所有暫存檔的名字都是隨機的(tempfile.mkstemp,O_CREAT|O_EXCL),
      而且遊戲檔與備份檔只要是符號連結就直接拒絕 —— 名字猜得到的暫存檔
      會被人先放一條指到別處的連結佔住,寫下去就穿過去改到資料夾外面的檔
    · --restore 之前先驗備份的 BIGF 檔頭長度,對不上就不敢還原
    · --restore 一份壞掉不連累另外兩份:壞的那份跳過並單獨列出來,好的照樣還原。
      只要有任何一份沒還原成功,exit code 就是 1 不是 0
    · **寫入之後把那一項讀回來複驗**(2026-09-05 加):照目錄表重讀一次,
      解出來跟要寫的不一樣就自動用備份還原,而且 exit code 是 1 不是 0
    · **寫入是先在工作複本上做、驗過才換上**(2026-10-29 改):封裝檔先複製成
      同資料夾的暫存檔,接資料、改目錄都做在複本上,讀回來驗過才 os.replace 換上。
      換名之前不管是磁碟滿、外接碟拔掉還是 Ctrl-C,遊戲檔都是原來那一份。
      代價是動手時要多一份跟封裝檔一樣大的空間,不夠就在動任何檔案之前停下來(exit code 2)
    · --export 不覆蓋既有的非 PNG 檔案 —— 那條路刻意不做備份,覆蓋掉沒東西救得回來
    · 寫入是附加模式,舊資料原地不動,所以就算新資料是壞的,舊的也還在檔案裡
    · 按 Ctrl-C 會告訴你遊戲檔到底有沒有被動到(動到就給你還原指令),
      而且 exit code 是 130 不是 0

做不到的事(先說,免得你白忙)
    · 只處理 DXT1(0x60)與 32 位元 ARGB(0x7D)兩種格式,其他一律拒絕,不亂猜
    · **splash 這一張只匯得出來,匯不回去。** 本站手上六份 data 資料夾全部被擋,
      連 --export 出來的那張原封不動匯回去也一樣:
        splash 是 1024x1024 的兩份(本站測試機那份被前人疊過的安裝,
        以及測試機自己留的那份備份資料夾)
            → 「新像素有 524288 個位元組,原本是 524304」
        splash 是 512x512 的四份(剛安裝好的原版英文版、剛安裝好的原版繁體中文版、
        PK 版、另一份社群繁體中文化版)
            → 「新像素有 131072 個位元組,原本是 131088」
      差的永遠是 16 個位元組:splash.fsh 裡那一筆記錄自己宣告的長度,
      比「16 個位元組的記錄檔頭 + 寬*高/2 的像素」多 16 個位元組,
      而 dxt1_encode 固定只吐 寬*高/2,長度對不起來就被 fsh_replace_pixels 擋掉。
      那多出來的 16 個位元組印出來看全是 0,本站沒查出它們是什麼;
      但宣告的長度確實指到下一筆附加記錄的開頭(緊接著那一筆自己寫著
      EAGL64 metal bin attachment for runtime texture management),
      所以不是本站把邊界算錯。
    · ⚠️ 上面那條**不是**「DXT1 一律寫不回去」:同一套編碼器在 models.big 的臉皮上
      長度是對的 —— 本站測試機那份 models.big,讀得出來的 889 張 DXT1 臉皮,
      889 張都是「宣告長度 = 16 + 寬*高/2」,0 個例外。
      卡住的是 splash.fsh 這一筆記錄的長度欄位,不是 DXT1 這個格式
    · 不縮放、不轉格式、不旋轉。尺寸不同就拒絕寫入
    · 不改圖在畫面上的位置與大小(那寫在版面檔 .fel 裡,不是這一支的事)
    · --selftest 只驗檔案安全那幾道守門員(備份 / 還原 / 符號連結 / 輸出路徑),
      而且那幾道也不是每一道都配到餌(數字見檔尾 selftest() 的說明)。
      DXT1、QFS、SHPI 那些格式細節驗不到 —— 那要有真的遊戲檔才驗得動
    · DXT1 是有損壓縮,匯出成 PNG 再原封不動匯回去,像素不保證逐位元組相同
      (原因見 _endpoint_candidates 的說明)
    · **開機大圖目前根本匯不回去。** 本站量得到的五份安裝(剛安裝好的英文原版、
      剛安裝好的中文原版、本站測試機那份 2023 模組版,以及歷史資料裡的 PK 版與
      NEW TC_PATCH123 版)的 splash.fsh,圖片記錄宣告的長度都比「記錄檔頭 16
      + 像素」多出 16 個位元組,而且那 16 個都是零。fsh_replace_pixels 的長度
      守門員因此一律拒絕,訊息長這樣:
          新像素有 131072 個位元組,原本是 131088
      (512x512 那四份是這個數字;測試機那份 1024x1024 是 524288 對 524304。
       差的都是 16。)--info 與 --export 不受影響。
      真的換得動的是標題頁與讀取畫面那兩張 32 位元的圖,而且那兩張是無損的
    · 換完之後遊戲畫面實際長什麼樣,本站沒有看過

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

import os
import re
import sys
import zlib
import struct
import shutil
import argparse
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 只看開頭,看不出後面少了多少。

# ── 被改過的檔案清單(給 Ctrl-C 用)─────────────────────────────────
# 按下 Ctrl-C 的人最想知道的只有一件事:「我的遊戲檔現在是好的還是壞的?」
# 沒有這份清單就只能講模糊話。規則:**真的動到正本之後才登記**
# —— 寫入與還原都是先做在暫存檔上,等 os.replace 換上去才算動到正本,
# 在那之前正本一個位元組都沒被碰過(2026-10-29 起寫入也是這樣;
# 以前寫入是對正本接資料,寫下第一個位元組就算動到)。
# 換名這一步的登記排在 os.replace **之前**、確定沒換成才收回,見 _swap_in(寫入與還原共用 _swap_tracked)。
# 換名出事之後分三種:確認換上了(留在 _TOUCHED)、確認沒換上(收回)、
# 問磁碟也問不出來(留在 _TOUCHED,另外記在 _UNSURE,訊息改講「沒辦法確認」)。
_TOUCHED = []
_UNSURE = []


def _mark_touched(path):
    p = os.fspath(path)
    if p not in _TOUCHED:
        _TOUCHED.append(p)
    if p in _UNSURE:
        _UNSURE.remove(p)


def _mark_unsure(path):
    """登記成「可能動過了,但沒辦法確認」:留在 _TOUCHED(保守),另外記一筆。"""
    p = os.fspath(path)
    if p not in _TOUCHED:
        _TOUCHED.append(p)
    if p not in _UNSURE:
        _UNSURE.append(p)


def _is_unsure(path):
    p = os.fspath(path)
    return p in _TOUCHED and p in _UNSURE


def _unmark_touched(path):
    """把登記收回去。只在**確定沒有換成**的時候才可以呼叫(見 _swap_in)。"""
    p = os.fspath(path)
    if p in _TOUCHED:
        _TOUCHED.remove(p)
    if p in _UNSURE:
        _UNSURE.remove(p)


def _symlink_complaint(path, what):
    """這個路徑是符號連結就回一句話,不是就回 None。

    ⚠️ 一定要用 os.path.islink / os.path.lexists,**不可以**用 os.path.exists:
       exists() 會穿過連結去問「另一頭在不在」,連結指到一個不存在的檔時
       它回 False —— 於是「這個名字沒被佔用」是假的,接著 open(..., 'wb')
       就會穿過連結,在資料夾外面生出一個檔(或蓋掉那裡的檔)。
    """
    if not os.path.islink(path):
        return None
    try:
        tgt = os.readlink(path)
    except OSError:
        tgt = '(讀不出來)'
    return ('%s(%s)是一個符號連結,指向 %s —— 本工具不在符號連結上寫東西。\n'
            '     寫下去會穿過連結去改「另一頭那個檔」,而那個檔不在本工具的\n'
            '     備份範圍內,弄壞了 --restore 也救不回來。\n'
            '     請把它換成真正的檔案,或直接對連結另一頭的那個資料夾跑本工具。'
            % (what, path, tgt))


def _same_bytes(a, b):
    """兩個檔逐位元組相同才回 True。先比大小,再一塊一塊往下讀。

    ⚠️ 不可以用 zip() 把兩邊拉在一起比 —— zip 會在短的那一邊停下來,
       寫到一半的檔反而會被判成「完全相同」。
    """
    if os.path.getsize(a) != os.path.getsize(b):
        return False
    with open(a, 'rb') as fa, open(b, 'rb') as fb:
        while True:
            x = fa.read(1 << 20)
            y = fb.read(1 << 20)
            if x != y:
                return False
            if not x:
                return True


def _new_temp_beside(dst, tag):
    """在 dst 那個資料夾裡開一個「別人搶不到」的暫存檔,回傳 (fd, 路徑)。

    ── 2026-09-05 改掉可預測的暫存檔名(上線前資安稽核 🔴)────────────────
    原本是 `part = dst + '.part'` 然後 shutil.copy2 過去。名字是**猜得到的**:
    只要有人先在那個資料夾放一個叫 `<目標>.part` 的符號連結指到資料夾外面,
    copy2 就會穿過它,把外面那個檔截成 0 再寫進去 —— 後面的 os.replace 只換掉
    連結本身,但外面那個檔在那之前就已經被蓋掉了。
    tempfile.mkstemp 用的是 O_CREAT|O_EXCL:名字先被佔走就直接失敗,
    而且它產生的名字帶隨機碼,事前放不了餌。權限也只有 0600,寫的過程中
    別人看不到半成品。

    暫存檔一定要跟 dst 在**同一個資料夾**:os.replace 只有在同一個檔案系統
    內才是原子的,丟到 /tmp 再搬回來就不是了。
    """
    d = os.path.dirname(os.path.abspath(os.fspath(dst))) or '.'
    return tempfile.mkstemp(dir=d, prefix='.' + os.path.basename(os.fspath(dst)) + tag)


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

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

    現在寫進去的是 mkstemp 開出來的唯一暫存檔(理由見 _new_temp_beside),
    而且完整寫完 + fsync + 逐位元組比對過,才改名成正式的備份檔名。
    中途被中斷時壞掉的是那個暫存檔,而且會被刪掉;
    正式那個名字要嘛還沒出現、要嘛就是完整的。
    """
    src, dst = os.fspath(src), os.fspath(dst)
    for p, what in ((src, '要備份的那個檔'), (dst, '備份檔')):
        why = _symlink_complaint(p, what)
        if why:
            raise DataError(why)
    fd, part = _new_temp_beside(dst, '.backup-')
    try:
        with os.fdopen(fd, 'wb') as out:
            with open(src, 'rb') as f:
                shutil.copyfileobj(f, out, 1 << 20)
            out.flush()
            os.fsync(out.fileno())
        # 備份是最後一道防線,所以不只比大小,整份讀回來比一次。
        # 幾 MB 的檔案而已,而且一輩子只做第一次。
        if not _same_bytes(src, part):
            raise DataError('備份寫到一半就對不上了(%s)—— 沒有備份成功,'
                            '遊戲檔一個位元組都沒有動。磁碟空間夠嗎?' % dst)
        try:
            shutil.copystat(src, part)     # 修改時間與權限一起抄過去
        except OSError:
            pass                           # 抄不動只是備份的日期不好看,內容是對的
        os.replace(part, dst)          # os.replace 是原子的
    except BaseException:
        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 MB 多 → 不到 1 MB
                                      (它還印「複驗:內容與備份相同 ✅」)
    最後那個會讓遊戲**完全開不起來**,而站上每一課都寫著「隨時可以 --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 道負責。
    """
    import struct
    bak, dst = os.fspath(bak), os.fspath(dst)
    # 符號連結的檢查要排在 exists() 之前:指到不存在的檔的連結,exists() 會說
    # 「找不到備份」,那句話是假的 —— 備份的名字明明被佔住了,只是佔它的是一條
    # 指到別處的連結。講錯原因會讓人往錯的方向找。
    for pth, what in ((bak, '備份檔'), (dst, '要還原的遊戲檔')):
        why = _symlink_complaint(pth, what)
        if why:
            raise SystemExit(why)
    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 的檔頭第 4-8 個位元組寫著它自己應該有多大。
    # 備份被截斷時這一欄一定對不上。兩種位元組順序都接受,理由見 size_field_order。
    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 道與第 4 道:語系檔與執行檔。
    # ⚠️ 這一支只動 .big,所以這兩段在本檔實際上跑不到。留著是因為
    #    這整段還原防線在本站多支腳本之間是同一份,要改就得一起改;
    #    有人把這一支複製去改別的檔案時,防線也還在。
    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('讀不出語系檔的結構,它壞了。')

    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):
    """真正的還原動作。**先寫暫存檔、驗完才換上**,正本不會有中間狀態。

    獨立成一支,是為了讓上面每一道檢查都以 return _do_copy(...) 收尾:
    檢查沒過就永遠走不到這裡,不會有「檢查完忘了 return」那種漏網。

    ── 2026-09-05 改成原子還原(上線前資安稽核 🔴)──────────────────────
    在這之前這裡是一行 `shutil.copy2(bak, dst)`。copy2 是「**先把 dst 截成
    0 bytes**,再一塊一塊寫進去」——「還原」這個動作本身就會先毀掉正本。
    只要複製途中發生 Ctrl-C、磁碟滿、外接碟被拔掉或程式崩潰,玩家的遊戲檔
    就停在 0 或半截,而他跑這一行的時機正是「遊戲已經開不起來」的時候。
    複驗擋得住「已經壞了」,擋不住「正在壞」。

    現在的順序是:
      1. 目標與備份都不可以是符號連結(理由見 _symlink_complaint)
      2. 在**正本那個資料夾**開一個 mkstemp 暫存檔,把備份寫進去
      3. flush + fsync,把權限從正本抄過來
      4. 大小相同 + 逐位元組相同(比完整長度,理由見 _same_bytes)
      5. 全過了才 os.replace 換上(走 _swap_tracked,登記與 --import 同一套)—— 這一步是原子的,不會有中間狀態
    任何一步失敗都刪掉暫存檔,正本原封不動;正本的內容只有兩種可能:
    「還是改過的樣子」或「完整還原好了」。
    """
    bak, dst = os.fspath(bak), os.fspath(dst)
    for pth, what in ((dst, '要還原的遊戲檔'), (bak, '備份檔')):
        why = _symlink_complaint(pth, what)
        if why:
            raise SystemExit(why)
    fd, tmp = _new_temp_beside(dst, '.restore-')
    try:
        with os.fdopen(fd, 'wb') as out:
            with open(bak, 'rb') as f:
                shutil.copyfileobj(f, out, 1 << 20)
            out.flush()
            os.fsync(out.fileno())
        # mkstemp 開出來的檔是 0600。不把權限抄回去的話,還原完的遊戲檔會變成
        # 只有自己讀得到 —— 遊戲本身跑得動,但這不是玩家原本的樣子。
        try:
            shutil.copymode(dst, tmp)
        except OSError:
            os.chmod(tmp, 0o644)
        if os.path.getsize(tmp) != os.path.getsize(bak):
            raise SystemExit(
                '還原寫到一半就對不上大小了(%d vs %d)—— **沒有動到 %s**,\n'
                '  它還是你跑這行之前的樣子。多半是磁碟空間不夠,清出空間再跑一次。'
                % (os.path.getsize(tmp), os.path.getsize(bak), dst))
        if not _same_bytes(bak, tmp):
            raise SystemExit(
                '還原寫出來的內容跟備份對不上 —— **沒有動到 %s**,'
                '它還是你跑這行之前的樣子。' % dst)
        # 登記排在換名之前、出事去問磁碟換上了沒(見 _swap_tracked),不是換完才登記
        _swap_tracked(tmp, dst)        # 到這一步才真的換上,而且是原子的
    except BaseException:
        try:
            os.remove(tmp)
        except OSError:
            pass
        raise




try:
    sys.stdout.reconfigure(encoding='utf-8')
    sys.stderr.reconfigure(encoding='utf-8')
except Exception:
    pass


class DataError(Exception):
    """檔案內容跟預期不符。一律當成「停下來問人」,不要猜。"""


class NoSpaceError(DataError):
    """硬碟放不下工作複本。發生在動任何檔案之前,main 用 exit code 2 回報。"""


# 兩個上限都是防呆:檔案自己宣告的數字不可以無限相信。一個被動過手腳的檔
# 可以宣稱「解壓後有 4 GB」或「目錄有一億項」,照著配置記憶體就當場把機器吃垮。
MAX_UNCOMPRESSED = 64 * 1024 * 1024     # QFS 解壓後的位元組上限
MAX_BIG_ENTRIES = 200000                # 封裝檔目錄項目數上限
# 備份用自己的副檔名,不叫 .bak。別課的腳本也會在同一個資料夾留備份,
# 名字撞在一起會互相覆蓋,而且還原的時候你分不出來是哪一課留的。
BACKUP_SUFFIX = '.screenbak'


# ─────────────────────────────────────────────────────────
#  QFS(EA 的壓縮格式,檔頭是 10 FB)
# ─────────────────────────────────────────────────────────
def qfs_decompress(data):
    """QFS(社群也叫它 RefPack)解壓。傳進來的不是 QFS 就原樣回傳。

    檔頭:第 0 個位元組是旗標,第 1 個固定 0xFB,這就是「10 FB」的由來。
    旗標的 bit0 決定「解壓後有多大」寫在哪裡:
        bit0 = 1   大小在 +0x06 起的 4 個位元組,壓縮資料從 +0x0A 開始
        bit0 = 0   大小在 +0x02 起的 3 個位元組,壓縮資料從 +0x05 開始
    這兩個長度欄位都是 big-endian,跟後面 BIGF 目錄一致,
    但跟 SHPI 內部那些欄位相反(那邊是 little-endian)。看錯就整個解不出來。
    """
    if len(data) < 2 or data[1] != 0xFB:
        return data
    if data[0] & 0x01:
        if len(data) < 10:
            raise DataError('QFS 檔頭不完整')
        size = int.from_bytes(data[6:10], 'big'); pos = 10
    else:
        if len(data) < 5:
            raise DataError('QFS 檔頭不完整')
        size = int.from_bytes(data[2:5], 'big'); pos = 5
    if not 0 <= size <= MAX_UNCOMPRESSED:
        raise DataError('QFS 宣稱解壓尺寸異常:%d' % size)

    out = bytearray()
    end = len(data)

    def copy_back(offset, length):
        # 這一行擋的是「往回的距離」:至少要往回 1 個位元組,而且不能往回到
        # 還沒解出來的地方。它管的不是輸出總長度,總長度由下面那道擋。
        if not 0 < offset <= len(out):
            raise DataError('QFS 反向參照越界 offset=%d' % offset)
        src = len(out) - offset
        for _ in range(length):
            out.append(out[src]); src += 1
        # ⚠️ 只檢查檔頭宣稱的大小是不夠的:那是「檔案自己說的」。
        #    一個惡意檔可以宣稱很小(通過上面那道),再用反向參照無限吐資料,
        #    把記憶體吃光。實際輸出也必須有上限,而且上限就是它自己宣稱的大小。
        if len(out) > size:
            raise DataError('QFS 解出來的資料超過檔頭宣稱的 %d 位元組' % size)

    # 接下來一路讀「控制位元組」。看第一個位元組落在哪個值域就知道這是哪一種指令:
    #   0x00-0x7F  兩個位元組一組:抄 0-3 個原文,再往回 1-1024 複製 3-10 個
    #   0x80-0xBF  三個位元組一組:抄 0-3 個原文,再往回 1-16384 複製 4-67 個
    #   0xC0-0xDF  四個位元組一組:抄 0-3 個原文,再往回 1-131072 複製 5-1028 個
    #   0xE0-0xFB  只抄原文,一次 4 到 112 個(一定是 4 的倍數)
    #   0xFC-0xFF  結束,順便抄最後 0-3 個原文
    # 「往回複製」的距離是相對於「已經解出來的尾端」,所以邊解邊長,
    # 複製到自己剛剛才寫出去的位元組是正常的,重複的花紋就是這樣壓的。
    while pos < end:
        b0 = data[pos]
        if b0 >= 0xFC:
            n = b0 & 0x03; pos += 1
            out += data[pos:pos + n]; break
        if b0 >= 0xE0:
            n = ((b0 & 0x1F) << 2) + 4; pos += 1
            out += data[pos:pos + n]; pos += n; continue
        if b0 >= 0xC0:
            b1, b2, b3 = data[pos + 1], data[pos + 2], data[pos + 3]; pos += 4
            n = b0 & 0x03
            length = ((b0 & 0x0C) << 6) + b3 + 5
            offset = ((b0 & 0x10) << 12) + (b1 << 8) + b2 + 1
        elif b0 >= 0x80:
            b1, b2 = data[pos + 1], data[pos + 2]; pos += 3
            n = (b1 >> 6) & 0x03
            length = (b0 & 0x3F) + 4
            offset = ((b1 & 0x3F) << 8) + b2 + 1
        else:
            b1 = data[pos + 1]; pos += 2
            n = b0 & 0x03
            length = ((b0 & 0x1C) >> 2) + 3
            offset = ((b0 & 0x60) << 3) + b1 + 1
        out += data[pos:pos + n]; pos += n
        copy_back(offset, length)
    return bytes(out[:size])


def qfs_compress_literal(data):
    """純 literal 編碼:不做字串比對,瞬間完成,格式一樣合法。

    壓出來比 EA 原本的大(大約等於原始大小),但因為我們是接到檔尾,
    大一點沒有影響。換來的是速度快上千倍,而且不可能壓錯。
    """
    n = len(data)
    # ⚠️ 這個短檔頭只有 3 個位元組寫「解壓後大小」,上限 16,777,215。
    #    超過就會被無聲截成低 24 位元,壓出來的 QFS 會宣告一個錯的長度,
    #    接進封裝檔就是一項壞資料。2026-09-05 下餌實測(不碰任何遊戲檔):
    #      16,777,215 → 宣告 16,777,215,解回來一樣長 ✅
    #      16,777,216 → 宣告 0        ❌
    #      16,777,232 → 宣告 16       ❌
    #    本課三張圖解開後最大 2,097,296 bytes,現況碰不到這一條;
    #    擋在這裡是給「把這一段複製去改別的封裝檔」的人。
    if n > 0xFFFFFF:
        raise DataError('這一段有 %s 個位元組,超過 QFS 短檔頭能表示的 16,777,215 —— '
                        '拒絕壓縮,免得寫出一個長度是錯的封裝檔' % format(n, ','))
    # 檔頭 5 個位元組:0x10 是旗標(bit0 = 0,代表用短的那種長度欄位),
    # 0xFB 是 QFS 的招牌,後面 3 個位元組是解壓後大小,big-endian。
    out = bytearray([0x10, 0xFB, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF])
    # 0xE0 那一族一次只能抄 4 的倍數、最多 112 個,
    # 所以先把尾巴不足 4 個的那幾個位元組切開,留給最後的結束指令帶走。
    tail = n % 4
    body = n - tail
    pos = 0
    while pos < body:
        chunk = min(112, body - pos)
        out.append(0xE0 | ((chunk - 4) // 4))
        out += data[pos:pos + chunk]
        pos += chunk
    # 0xFC 那一族是結束記號,低 2 個位元順便帶走最後不足 4 個的那幾個位元組。
    out.append(0xFC | tail)
    out += data[pos:]
    return bytes(out)


# ─────────────────────────────────────────────────────────
#  BIGF 封裝檔:讀目錄、接到檔尾
# ─────────────────────────────────────────────────────────
def size_field_order(path):
    """檔頭 +0x04 的「檔案總大小」是 little 還是 big endian。

    ⚠️ 這一欄兩種順序都遇得到,不能寫死,也不能照檔名或檔案大小猜。
       「哪個檔是哪一種」不是這個格式天生的性質,是看你手上這一份被誰重新打包過。
       本站以 BIGF 檔頭(不是副檔名)認過四份 data 資料夾:剛安裝好的原版英文版
       207 個封裝檔、剛安裝好的原版繁體中文版 205 個、PK 版 205 個,這三份全部
       是 little-endian,連它們裡面最大的兩個封裝檔 models.big(172,992,803 個
       位元組)與 frontend/portrait.big(109,291,217 個位元組)也是 little-endian。
       只有本站測試機那份疊過模組的 data 資料夾出現 big-endian:384 個封裝檔裡
       有 10 個是 big-endian,而且只落在 7 個檔名上:models.big、
       frontend/portrait.big、audio 底下的 pnamehdr.big 與 pnamedat.big、
       球場夜間檔 coornite.big、dodgnite.big、wrignite.big(夜間那三個各出現
       兩次,因為測試機留了一份球場備份資料夾)。這 7 個檔名在三份原版裡
       全部是 little-endian。
       所以不要照檔名或檔案大小記,讀出來是哪一種就照哪一種寫回去。
    """
    n = os.path.getsize(path)
    with open(path, 'rb') as f:
        raw = f.read(8)
    if len(raw) < 8:
        raise DataError('%s 太小,不像封裝檔' % os.path.basename(path))
    if struct.unpack('<I', raw[4:8])[0] == n:
        return '<'
    if struct.unpack('>I', raw[4:8])[0] == n:
        return '>'
    raise DataError('%s 的檔頭大小欄位跟實際檔案大小對不上 —— 這個檔可能已經損毀'
                    % os.path.basename(path))


def big_entries(path):
    """回傳 [(名稱, 目錄欄位位置, 資料 offset, 資料長度)]。

    目錄欄位位置留著,是為了之後只改那 8 個位元組,不必重寫整個目錄。
    """
    # BIGF 檔頭 16 個位元組:0-4 招牌 BIGF、4-8 檔案總大小、
    # 8-12 目錄項目數、12-16 目錄區大小。除了「檔案總大小」那一欄之外
    # 全部是 big-endian(那一欄兩種順序都遇得到,見 size_field_order)。
    try:
        with open(path, 'rb') as f:
            head = f.read(16)
            if len(head) < 16 or head[:4] != b'BIGF':
                raise DataError('%s 的開頭不是 BIGF,這不是封裝檔' % os.path.basename(path))
            count = int.from_bytes(head[8:12], 'big')
            if not 0 < count < MAX_BIG_ENTRIES:
                raise DataError('%s 的目錄項目數異常(%d)' % (os.path.basename(path), count))
            blob = head + f.read(count * 80 + 8192)
    except OSError as e:
        raise DataError('讀不到 %s:%s' % (path, e))

    # 目錄項目沒有固定長度:每一項是 4 個位元組的 offset、4 個位元組的長度
    # (兩個都是 big-endian),接一個以 NUL 結尾的檔名。
    # 所以只能一項一項往前走,不能用乘法直接跳到第 n 項。
    items = []
    pos = 16
    for _ in range(count):
        if pos + 8 > len(blob):
            break                                   # 目錄比預估長,已讀到的就夠用
        field = pos
        off = int.from_bytes(blob[pos:pos + 4], 'big')
        size = int.from_bytes(blob[pos + 4:pos + 8], 'big')
        pos += 8
        end = blob.find(b'\x00', pos)
        if end < 0:
            break
        items.append((blob[pos:end].decode('latin-1', 'replace'), field, off, size))
        pos = end + 1
    return items


def read_entry(path, off, size):
    """照目錄給的 offset 與長度,把那一項的原始位元組讀出來。

    讀不滿就報錯,不回半截資料。半截的 QFS 解出來是垃圾,
    而垃圾一路往下傳只會在很後面才炸,那時候已經看不出問題出在這裡。
    """
    with open(path, 'rb') as f:
        f.seek(off)
        data = f.read(size)
    if len(data) != size:
        raise DataError('目錄說這一項有 %d 個位元組,實際只讀到 %d' % (size, len(data)))
    return data


def append_entry(work, field_pos, blob):
    """把 blob 接到 work 的檔尾,只改該項目的目錄 8 bytes 與檔頭的 4 bytes。

    原本的資料一個位元組都不動 —— 所以就算新資料是壞的,舊資料還在檔案裡。

    ⚠️ **work 是工作複本,不是玩家的遊戲檔**(2026-10-29 改,見 _stage_copy)。
       以前這裡直接對遊戲檔開 'r+b',寫一半遇到 OSError(磁碟滿、fsync 失敗)
       就留下檔尾多了半截資料的封裝檔,而呼叫端還是說「遊戲檔一個位元組都沒有動」。
       現在寫的是複本,所以這裡**不登記**「動過了」,出事也不必收拾:
       整份複本丟掉就好。真正會動到遊戲檔的那一刻是 _swap_in。
    """
    # 位元組順序一定要在寫入之前量:一旦接了資料上去,檔案大小就跟檔頭那一欄
    # 對不上了,那時候再問「是 little 還是 big」會變成兩邊都不成立。
    order = size_field_order(work)          # 一定要在改檔案之前先量
    why = _symlink_complaint(work, '要寫入的封裝檔')
    if why:
        raise DataError(why)
    # 新資料放在原本的檔尾,所以新的 offset 就是「現在的檔案大小」。
    new_off = os.path.getsize(work)
    with open(work, 'r+b') as f:
        f.seek(0, os.SEEK_END)
        f.write(blob)
        total = f.tell()
        f.seek(field_pos)
        f.write(struct.pack('>II', new_off, len(blob)))     # 目錄一律 big-endian
        f.seek(4)
        f.write(struct.pack(order + 'I', total))
        # 先確定資料真的落到磁碟,再回報成功。少了這一步,萬一當下斷電,
        # 目錄已經指到新位置而新資料還在快取裡,這個封裝檔就壞了。
        f.flush()
        os.fsync(f.fileno())
    return new_off


# ── 複本上做、最後一次換名(2026-10-29 改)─────────────────────────────
# 以前 --import --apply 是直接對遊戲檔用 'r+b' 往檔尾接資料,再改目錄與檔頭。
# 寫到一半遇到 OSError(磁碟滿、外接碟拔掉、fsync 失敗)時,檔尾已經多了半截資料,
# 檔頭或目錄可能已經指到它 —— 而畫面印的是「遊戲檔一個位元組都沒有動」。
# 本站「把一套球衣裝進遊戲」「做一張新臉」那幾課早就改成下面這個寫法,這一支跟上:
#   遊戲檔 → 同資料夾的工作複本(mkstemp,名字猜不到)→ 接資料、改目錄都做在複本上
#   → 把複本照目錄表讀回來驗過 → 一次 os.replace 換上去(_swap_in)→ 再讀遊戲檔驗一次。
# 換名之前任何一步失敗,整份複本丟掉就好,遊戲檔一個位元組都沒有被寫過;
# 換名本身是原子的,遊戲檔只有「原來那一份」與「換好的那一份」兩種樣子。
# 代價:動手的時候硬碟要多一份跟封裝檔一樣大的空間(見 _require_space)。
def _drop(path):
    """把暫存檔收掉。收不掉也不能蓋過原本要丟出去的那個錯誤,所以吞掉。"""
    try:
        if os.path.lexists(path):
            os.remove(path)
    except OSError:
        pass


def _leftover_note(path, live):
    """_drop 之後問磁碟:工作複本真的不在了嗎?不在回 None,還在(或問不到)回一段要印給讀者的話。

    不用 os.path.lexists:它在「問不到」的時候也回 False,會把「碟不見了」讀成「刪掉了」。
    找不到檔,而且它所在的資料夾還在,才算真的刪掉了。
    """
    folder = os.path.dirname(os.path.abspath(path))
    name, live_name = os.path.basename(path), os.path.basename(live)
    try:
        os.lstat(path)
    except OSError as e:
        if isinstance(e, FileNotFoundError) and os.path.isdir(folder):
            return None
        return ('沒辦法確認 %s 旁邊的工作複本 %s 有沒有刪掉(那個資料夾現在讀不到)。\n'
                '  碟接好之後看一下,還在的話可以自己刪掉它 —— 遊戲不會讀它。' % (live_name, name))
    return ('%s 旁邊留下一個沒刪掉的工作複本:%s\n'
            '  遊戲不會讀它,可以自己刪掉(外接碟被拔掉的話,接好之後再刪)。' % (live_name, name))


def _stage_copy(live):
    """把遊戲檔整份複製成它旁邊的工作複本,驗過之後回傳工作複本的路徑。

    · 先擋符號連結,再用 'r+b' 把遊戲檔開一下就關 —— **一個位元組都不寫**。
      這一下是為了讓「這個檔寫不進去」(被設成唯讀、遊戲正開著)在這裡就出事,
      錯誤訊息指著讀者認得的封裝檔,不是一個他沒見過的工作複本名字。
    · 工作複本跟遊戲檔放同一個資料夾:最後那一步 os.replace 才是同一個檔案系統內
      的改名(原子的)。名字是 mkstemp 取的,別人沒辦法先佔一個連結在那裡等。
    · 複製完 fsync、權限對成遊戲檔原本的樣子,再**逐位元組讀回來比**:
      對不上就丟 DataError —— 那時候遊戲檔還是原來那一份。
    · 任何一步出事(含 Ctrl-C)都先把複本刪掉再往上丟。
    """
    live = os.fspath(live)
    why = _symlink_complaint(live, '要改的封裝檔')
    if why:
        raise DataError(why)
    with open(live, 'r+b'):
        pass                                # 只開不寫:確認寫得進去,不動任何位元組
    fd, tmp = _new_temp_beside(live, '.work-')
    try:
        with os.fdopen(fd, 'wb') as out:
            with open(live, 'rb') as f:
                shutil.copyfileobj(f, out, 1 << 20)
            out.flush()
            os.fsync(out.fileno())
        # mkstemp 開出來的檔是 0600。換上去之後遊戲檔的權限要跟原本一樣。
        try:
            shutil.copymode(live, tmp)
        except OSError:
            os.chmod(tmp, 0o644)
        if not _same_bytes(live, tmp):
            raise DataError('複製出來的工作複本跟 %s 對不上(多半是硬碟空間不夠或那顆碟有問題)。\n'
                            '     這份複本不會被換上去,%s 一個位元組都沒有動。'
                            % (os.path.basename(live), os.path.basename(live)))
    except BaseException:
        _drop(tmp)
        raise
    return tmp


def _mb(n):
    return '%.1f MB' % (n / 1048576.0)


def _presence(path):
    """問磁碟:這個路徑在不在。回 True(在)/ False(確定不在)/ None(問不到)。

    不用 os.path.lexists:它問不到(碟不見了)也回 False,會把「碟不見了」讀成「檔不在」。
    找不到檔、而且它所在的資料夾還讀得到,才算確定不在。
    """
    try:
        os.lstat(path)
        return True
    except FileNotFoundError:
        return False if os.path.isdir(os.path.dirname(os.path.abspath(path))) else None
    except OSError:
        return None


def _unmeasurable_note(bigpath, err):
    """_require_space 量不到封裝檔大小時要印的話。照磁碟「現在」的樣子講,問不到就說問不到。

    外接碟在這一步之前剛好不見了:到這裡為止只讀過、沒寫過任何東西,所以這句
    「遊戲檔與備份都沒有被碰過」是真的(2026-10-30 前這裡直接漏出 traceback)。
    """
    base = os.path.basename(bigpath)
    bak = bigpath + BACKUP_SUFFIX
    here, bak_here = _presence(bigpath), _presence(bak)
    if here is False:
        now = '%s 現在不在那個資料夾裡了(資料夾還在)' % base
    elif here:
        now = '%s 還在,只是讀不出它的大小' % base
    else:
        now = '%s 所在的資料夾現在讀不到(外接碟被拔掉了嗎?)' % base
    if bak_here:
        bak_now = '本工具先前做的備份(%s)還在' % os.path.basename(bak)
    elif bak_here is False:
        bak_now = '本工具還沒替它做過備份'
    else:
        bak_now = '備份在不在現在也看不到'
    return ('還沒動手就量不到 %s 的大小(%s)。\n'
            '     %s;%s。\n'
            '     到這裡為止只讀過、沒寫過任何東西:遊戲檔與備份都沒有被碰過。\n'
            '     碟接好、確認 %s 還在之後,再跑一次同一行。它真的不見了的話,\n'
            '     有備份就跑 --restore 補回來,沒有備份就從你自己的完整備份複製一個回來。'
            % (base, err.strerror or err, now, bak_now, base))


def _require_space(bigpath, with_backup, extra):
    """動手之前先看封裝檔那顆硬碟放不放得下,不夠就丟 NoSpaceError(結束代碼 2)。

    複本路線的代價就在這裡:工作複本跟封裝檔一樣大(再加要接上去的新資料),
    第一次 --apply 還要再加一份備份,而且兩份同時存在。
    with_backup 是「這一次要不要做備份」(已經有備份就不做)。
    量不到(shutil.disk_usage 丟例外)就不擋:量不到不等於不夠 ——
    真的不夠的時候作業系統會報錯,那條路一樣會照實說遊戲檔動了沒。
    """
    folder = os.path.dirname(os.path.abspath(bigpath)) or '.'
    try:
        size = os.path.getsize(bigpath)
    except OSError as err:
        raise DataError(_unmeasurable_note(bigpath, err))
    need = size + extra + (size if with_backup else 0)
    try:
        free = shutil.disk_usage(folder).free
    except (OSError, AttributeError):
        return
    if free < need:
        raise NoSpaceError(
            '硬碟空間不夠:這一次要在 %s 旁邊暫時多放 %s(%s),\n'
            '     那顆硬碟現在只剩 %s。清出空間再跑一次同一行。\n'
            '     到這裡為止遊戲檔與備份都還沒被碰過。'
            % (os.path.basename(bigpath), _mb(need),
               '一份備份加一份工作複本' if with_backup else '一份工作複本', _mb(free)))


def _identity_of(path):
    """檔案的「身分」(st_dev, st_ino, 大小, 修改時間):同一個檔改名之後這四個值不變。

    問不到、或這個檔案系統不給有意義的 inode(st_ino 是 0)就回 None(認不出就不認)。
    用 lstat 不用 stat:不穿過符號連結。
    """
    try:
        st = os.lstat(path)
    except OSError:
        return None
    if not st.st_ino:
        return None
    return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)


_SWAPPED, _NOT_SWAPPED, _UNKNOWN = 'swapped', 'not-swapped', 'unknown'


def _swap_state(work, live, live_before, work_before):
    """os.replace 出事(或被 Ctrl-C 打斷)之後問磁碟:工作複本換上去了沒。三種答案:

    · _SWAPPED     遊戲檔現在的身分 = 換之前那份工作複本的身分(改名帶著身分走)。
    · _NOT_SWAPPED 遊戲檔現在的身分 = 換之前遊戲檔自己的身分,**沒有被換掉**。
                   認不出身分的檔案系統退一步:工作複本還在原處、遊戲檔也讀得到,
                   換名是原子的,複本還在就一定沒換成。
    · _UNKNOWN     其他一切:遊戲檔讀不到(碟不見了)、身分跟兩邊都對不上
                   (有別的程式動過它)、複本不在又認不出身分。**不猜。**
    「複本不見了」本身不能當證據:換成功了複本也會不見,防毒軟體把複本移走也是。
    """
    now = _identity_of(live)
    if now is not None:
        if work_before is not None and now == work_before:
            return _SWAPPED
        if live_before is not None and now == live_before:
            return _NOT_SWAPPED
        return _UNKNOWN
    if _presence(work) and _presence(live):
        return _NOT_SWAPPED
    return _UNKNOWN


def _swap_in(work, live):
    """把工作複本換上去 —— --import --apply 這條路上真的會動到遊戲檔的那一刻。

    換名與登記的細節在 _swap_tracked(--restore 的換名也走同一個函式,
    2026-10-30 起:以前還原是換完才登記,中斷落在兩步之間就漏登)。
    登記「動過了」排在 os.replace **之前**,而不是之後:Ctrl-C 落在換名前後任何
    一個位置,收尾講的都是安全的那一邊(寧可多叫人跑一次 --restore,
    也不要在檔案已經換掉的時候說「一個位元組都沒有動」)。
    換名出事之後,**不看旗標也不看「複本還在不在」,去問磁碟認檔案身分**
    (見 _swap_state),分三種:確認換上了 → 登記留著;確認沒換上 → 把登記收回去;
    問不出來 → 登記留著並記成「沒辦法確認」,訊息只在這種時候才用保守的講法。
    (2026-10-30 前只看「複本還在不在」:複本被防毒軟體移走、或外接碟斷線時
    換名根本沒發生,卻會宣稱遊戲檔「已經被動到了」。)
    """
    work, live = os.fspath(work), os.fspath(live)
    why = _symlink_complaint(live, '要換上去的封裝檔')
    if why:
        raise DataError(why)
    _swap_tracked(work, live)


def _swap_tracked(work, live):
    """os.replace(work, live) 加上登記:--import 的 _swap_in 與 --restore 的 _do_copy 共用。

    順序是:記下兩邊的身分 → 登記動過了 → 換名 → 換名出事才問磁碟收回或保留登記。
    登記一定在換名**之前**:換名成功之後、登記之前被 Ctrl-C 打斷的話,
    登記簿就漏掉一個已經換掉的檔,收尾會說「一個位元組都沒有動到」(假話)。
    """
    work, live = os.fspath(work), os.fspath(live)
    live_before = _identity_of(live)        # 換名之前先記下兩邊的身分,出事才認得出來
    work_before = _identity_of(work)
    already = live in _TOUCHED
    _mark_touched(live)
    try:
        os.replace(work, live)              # 到這一行才真的換上,而且是原子的
    except BaseException:
        state = _swap_state(work, live, live_before, work_before)
        if state == _NOT_SWAPPED:
            if not already:
                _unmark_touched(live)
        elif state == _UNKNOWN and not already:
            _mark_unsure(live)
        raise


# ─────────────────────────────────────────────────────────
#  FSH(SHPI 容器):找到那張圖、換掉像素
# ─────────────────────────────────────────────────────────
# 名字後面那個數字是「一個像素幾個位元組」,全部是本站在剛安裝好的原版上量出來的。
# 2026-08-28 訂正:原本這張表有四格名字跟量到的位元組數對不上(0x6D 寫成 4 位元組、
# 0x7B 寫成 2 位元組、0x7D 寫成 3 位元組、RGB24 這個名字掛錯代號),
# 而且漏了 0x79 與 0x7F 兩個代號 —— 漏掉會讓下面的守門員把真實的圖擋掉。
# 2026-08-28 再訂正:0x79 原本叫 EMPTY_1x1,那是把「用途」寫成了「格式」。
FSH_FORMATS = {
    0x60: 'DXT1',        # 0.5  每 4x4 像素一個 8 位元組區塊
    0x61: 'DXT3',        # 1.0  每 4x4 像素一個 16 位元組區塊,前 8 個是透明度
    0x6D: 'ARGB16_4444', # 2.0  models.big 裡 18,287 張
    0x78: 'RGB16_565',   # 2.0  models.big 裡 10,512 張
    0x79: 'PAL4',        # 0.5  4 位元索引色,調色盤在緊接的 0x2A 區塊裡
                         #      2026-08-28 訂正:原本寫 EMPTY_1x1「永遠 1×1」。
                         #      uniforms.big 的 431 個空槽確實都是 1×1,但那是用途不是格式:
                         #      中文字型的字圖集就是 0x79,1024x512、正好 0.500 bytes/像素,
                         #      而 au20b_en.ffn 後面的 0x2A 調色盤正好 16 個項目(2的4次方)。
    0x7B: 'PAL8',        # 1.0  一個位元組是索引,調色盤在緊接的 0x2A 區塊裡
    0x7D: 'ARGB32',      # 4.0  剛安裝好的原版 portrait.big 2,391 張圖全部是這個
    0x7E: 'ARGB16_1555', # 外部文件說的,本站至今找不到樣本,無從實測
    0x7F: 'RGB24',       # 3.0  models.big 裡 45 張(g001-g045.fsh)
}
# 名字對齊 FSHTOOL 1.22(Denis Auroux, 2002)說明書列的格式表;
# 每像素位元組數是本站在剛安裝好的原版上自己量的,兩邊完全吻合。
# 那份說明書還有一條本站沒用到的規則:代號 +0x80 代表「這張圖本身是壓縮過的」。


def fsh_first_image(data):
    """回傳 (格式代號, 寬, 高, 像素起點, 像素終點)。只看第一筆記錄。

    SHPI 檔頭 16 個位元組:0-4 招牌 SHPI、4-8 檔案長度、8-12 圖片筆數、
    12-16 產生它的工具代號。之後是目錄,每一筆 8 個位元組:
    4 個位元組的標籤(就是 --info 印出來的 firs / lice / memc),
    再 4 個位元組的位移。**這一段全部是 little-endian**,
    跟外面 BIGF 目錄的 big-endian 相反,兩個混著看必錯。

    每張圖自己的記錄開頭也是 16 個位元組:
    第 0 個位元組是格式代號,第 1-3 個是這一塊資料的長度(3 個位元組,
    little-endian),+4 與 +6 各 2 個位元組是寬與高,像素從 +16 開始。
    +8 到 +16 那 8 個位元組這支腳本沒有用到,本站也沒有驗過它們是什麼。
    """
    if len(data) < 16 or data[:4] != b'SHPI':
        raise DataError('不是 SHPI 檔(開頭是 %r)' % data[:4])
    num = struct.unpack_from('<I', data, 8)[0]
    if num < 1:
        raise DataError('這個 SHPI 裡一張圖都沒有')
    off = struct.unpack_from('<I', data, 20)[0]         # 16 + 0*8 + 4
    if off + 16 > len(data):
        raise DataError('圖片記錄的檔頭不完整')
    code = data[off]
    if code not in FSH_FORMATS:
        raise DataError('沒見過的格式代號 0x%02X' % code)
    block_size = data[off + 1] | (data[off + 2] << 8) | (data[off + 3] << 16)
    width = struct.unpack_from('<H', data, off + 4)[0]
    height = struct.unpack_from('<H', data, off + 6)[0]
    if not (0 < width <= 4096 and 0 < height <= 4096):
        raise DataError('圖片尺寸異常 %dx%d' % (width, height))
    # 像素從記錄開頭 +16 起。終點有三種判法,由可靠排到不可靠:
    #   1. 記錄自己宣告的長度(block_size 要大於 16 才算是真的有宣告)
    #   2. 沒宣告但後面還有第二張圖,就拿第二張的起點當這一張的終點
    #   3. 兩個都沒有,那就一路到檔尾
    # 這裡抓得準不準,直接決定寫回去時「新舊像素長度一不一樣」;
    # 長度不一樣 fsh_replace_pixels 會擋下來,所以不會默默寫壞,只會拒絕。
    # ⚠️ splash.fsh 就是卡在第 1 種判法:它宣告的長度比「16 + 寬*高/2」多 16。
    #    (本站測試機那份 splash 是 1024x1024,宣告 524320,而 16 + 寬*高/2 是 524304;
    #     剛安裝好的原版英文版與繁體中文版都是 512x512,宣告 131104,
    #     而 16 + 寬*高/2 是 131088。多出來的那 16 個位元組印出來看全是 0。)
    #    於是 end - start 永遠比 dxt1_encode 吐出來的 寬*高/2 多 16,匯入一定被擋。
    #    要讓它匯得回去,得先確定那 16 個位元組是什麼、該不該一起寫回去 ——
    #    沒確定之前不動這一段:寧可擋下來,也不要猜著寫。
    start = off + 16
    if block_size > 16:
        end = min(off + block_size, len(data))
    elif num > 1:
        end = min(struct.unpack_from('<I', data, 16 + 8 + 4)[0], len(data))
    else:
        end = len(data)
    return code, width, height, start, end


def fsh_replace_pixels(data, start, end, new_pixels):
    """把 [start, end) 這一段像素換掉,前後的位元組原封不動。

    長度一不一樣是這支腳本最重要的一道防線:SHPI 記錄裡的長度欄位、
    後面每一張圖的位移,全部是照原本的長度算出來的。
    只要差一個位元組,後面那些圖就整批對不上,所以這裡寧可直接拒絕。

    ⚠️ 下面那句「尺寸或格式不一致」對 splash 這一張是會誤導的:
    它的尺寸與格式其實都對,差的是那一筆記錄宣告的長度比像素多 16 個位元組
    (見 fsh_first_image 的說明)。看到 524288 對 524304、
    或 131072 對 131088 這兩組數字,就是那件事,不是你的圖有問題。
    """
    if len(new_pixels) != end - start:
        raise DataError('新像素有 %d 個位元組,原本是 %d —— 尺寸或格式不一致,拒絕寫入'
                        % (len(new_pixels), end - start))
    return data[:start] + new_pixels + data[end:]


# ─────────────────────────────────────────────────────────
#  DXT1:解碼與編碼
#
#  這一段只有**開機大圖那一張**用得到:SCREENS 裡的 splash,也就是
#  data/frontend/splash.big 的 splash.fsh。另外兩張(license.fsh 的 lice、
#  memcard.fsh 的 memc)是 32 位元的 0x7D,走下面另一條路,不經過這裡。
#  (本腳本不碰球員臉皮,models.big 從頭到尾沒有出現在 SCREENS 裡。)
#
#  DXT1 一張圖切成 4x4 的方塊,每塊只有 8 個位元組:
#    兩個底色(各 16 位元)+ 16 個 2 位元的索引
#  兩個底色會再內插出中間色,湊成調色盤,每個像素從裡面挑一個。
#
#  ⚠️ DXT1 有兩種模式,靠兩個底色誰大誰小決定:
#     c0 >  c1  四色模式:底色兩個 + 中間色兩個,全部不透明
#     c0 <= c1  三色模式:底色兩個 + 中間色一個,第四個索引代表**完全透明**
#  這是 DXT1 表達透明度的唯一辦法(它沒有獨立的透明度區塊)。
#  兩種模式在真的 splash.fsh 裡都遇得到,所以兩種都要能產生。
#
#  ⚠️ 尺寸不是固定的,不要把某一台量到的數字當通則。本站量到的 splash.fsh:
#     四份剛安裝好的安裝(英文版、中文版、PK 版、套過官方更新檔的那一份)
#     全部是 512x512;本站測試機那份是 1024x1024。格式兩邊都是 0x60。
#     所以 --import 之前先跑 --info,以它印出來的尺寸為準。
# ─────────────────────────────────────────────────────────
# 下面幾個是 DXT1 的零件。DXT1 的兩個底色是 16 位元的 565 格式:
# 紅 5 個位元、綠 6 個(人眼對綠色最敏感)、藍 5 個。
# 所以 8 位元的色值進出都要換算,而且換算本身就會掉精度。
def _clamp(v):
    """把算出來的色值壓回 0-255。外插出來的候選端點會超出這個範圍。"""
    return 0 if v < 0 else (255 if v > 255 else v)


def _dist(p, q):
    """兩個顏色差多少。用平方和不開根號(只拿來比大小,開根號是白花時間)。"""
    return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2


def _to565(r, g, b):
    """8 位元三色 → 16 位元 565。用四捨五入,不是直接把低位砍掉。"""
    return (((round(r * 31 / 255) & 0x1F) << 11) |
            ((round(g * 63 / 255) & 0x3F) << 5) |
            (round(b * 31 / 255) & 0x1F))


def _from565(c):
    """565 → 8 位元三色。乘 255 再除以該色的最大值,讓 0x1F 還原成 255。"""
    return (((c >> 11) & 0x1F) * 255 // 31,
            ((c >> 5) & 0x3F) * 255 // 63,
            (c & 0x1F) * 255 // 31)


def _palette(c0, c1, four):
    """把兩個底色展開成四格調色盤。

    四色模式的中間兩格是 1/3 與 2/3 的內插;三色模式只內插出中間那一格,
    第四格保留給「完全透明」,顏色寫什麼都不會被畫出來,所以填黑。
    """
    r0, g0, b0 = _from565(c0)
    r1, g1, b1 = _from565(c1)
    if four:
        return [(r0, g0, b0), (r1, g1, b1),
                ((2 * r0 + r1) // 3, (2 * g0 + g1) // 3, (2 * b0 + b1) // 3),
                ((r0 + 2 * r1) // 3, (g0 + 2 * g1) // 3, (b0 + 2 * b1) // 3)]
    return [(r0, g0, b0), (r1, g1, b1),
            ((r0 + r1) // 2, (g0 + g1) // 2, (b0 + b1) // 2), (0, 0, 0)]


def dxt1_decode(raw, w, h):
    """DXT1 → RGBA bytes

    一塊 8 個位元組:前 2 個是底色 c0、接著 2 個是 c1(都是 little-endian 的 565),
    後 4 個是 16 個像素各 2 個位元的調色盤索引,由低位往高位排,
    對應的像素順序是由左到右、由上到下。
    圖的寬高不是 4 的倍數時,最後一塊會有一部分落在圖外,那些位置直接跳過。
    """
    out = bytearray(w * h * 4)
    pos = 0
    for by in range((h + 3) // 4):
        for bx in range((w + 3) // 4):
            if pos + 8 > len(raw):
                break
            c0 = raw[pos] | (raw[pos + 1] << 8)
            c1 = raw[pos + 2] | (raw[pos + 3] << 8)
            lookup = (raw[pos + 4] | (raw[pos + 5] << 8) |
                      (raw[pos + 6] << 16) | (raw[pos + 7] << 24))
            pos += 8
            # 兩個底色誰大誰小,就決定了這一塊是四色還是三色模式。
            # 這是 DXT1 唯一的透明度開關,它沒有獨立的透明度區塊。
            four = c0 > c1
            pal = _palette(c0, c1, four)
            for py in range(4):
                for px in range(4):
                    x = bx * 4 + px; y = by * 4 + py
                    if x >= w or y >= h:
                        continue
                    i = py * 4 + px
                    idx = (lookup >> (2 * i)) & 0x03
                    r, g, b = pal[idx]
                    a = 0 if (not four and idx == 3) else 255
                    di = (y * w + x) * 4
                    out[di] = r; out[di + 1] = g; out[di + 2] = b; out[di + 3] = a
    return bytes(out)


def _endpoint_candidates(cols):
    """端點候選。

    DXT 解出來的方塊最多只有四種相異色,所以直接試遍所有配對就行(最多十組)。
    但端點不一定出現在像素裡 —— 方塊可能只用到中間的內插色。那種情況可以
    解回來:若 a、b 是 1/3 與 2/3 內插點,則 c0 = 2a - b、c1 = 2b - a。
    這幾個外插候選有沒有用,本站拿這一課自己的開機大圖量過(解出來、重編碼、
    再解一次,比對三個色通道完全相同的像素比例):
      剛安裝好的原版 512x512(英文版與中文版數字相同,歷史資料裡的
      PK 版與 NEW TC_PATCH123 版也是同一張)      66.4% → 89.2%
      本站測試機那份 2023 模組版 1024x1024        96.1% → 99.8%
    差多少要看那張圖長什麼樣,不是一個固定的數字。
    ⚠️ 這裡量的是「重編碼」,不是「寫回遊戲檔」:開機大圖目前會被長度
    守門員擋下來,見檔頭「做不到的事」那一條。
    """
    if len(cols) > 8:
        best = -1; pair = (cols[0], cols[0])
        for i in range(len(cols)):
            for j in range(i + 1, len(cols)):
                d = _dist(cols[i], cols[j])
                if d > best:
                    best, pair = d, (cols[i], cols[j])
        return [pair, (tuple(min(c[k] for c in cols) for k in range(3)),
                       tuple(max(c[k] for c in cols) for k in range(3)))]
    out = [(cols[i], cols[j]) for i in range(len(cols)) for j in range(i, len(cols))]
    for a, b in list(out):
        if a == b:
            continue
        for m, n, p, q in ((2, -1, a, b), (2, -1, b, a), (3, -2, b, a), (3, -2, a, b)):
            cand = tuple(_clamp(m * p[k] + n * q[k]) for k in range(3))
            out.append((p, cand)); out.append((q, cand))
    seen = set(); uniq = []
    for p in out:
        if p not in seen:
            seen.add(p); uniq.append(p)
    return uniq


def dxt1_encode(rgba, w, h):
    """RGBA bytes → DXT1。透明的像素用三色模式的索引 3 表達。

    一次處理 4x4 一塊,塊與塊之間互不相干,所以編碼順序跟解碼時完全一樣。
    圖的寬高不是 4 的倍數時,超出去的位置拿邊緣像素補(min(..., w - 1))。
    補邊緣比補黑色好:那些位置不會被畫出來,但會參與這一塊的取色。
    """
    out = bytearray()
    for by in range((h + 3) // 4):
        for bx in range((w + 3) // 4):
            px = []; al = []
            for py in range(4):
                for pxi in range(4):
                    x = min(bx * 4 + pxi, w - 1)
                    y = min(by * 4 + py, h - 1)
                    di = (y * w + x) * 4
                    px.append((rgba[di], rgba[di + 1], rgba[di + 2]))
                    al.append(rgba[di + 3])
            out += _encode_block(px, al)
    return bytes(out)


def _encode_block(px, alphas):
    """把一塊 4x4 編成 8 個位元組。

    做法是窮舉:可能的端點配對通通試一遍,算出每一組的誤差,取最小的。
    一塊只有 16 個像素、候選最多十幾組,窮舉比任何近似法都省事,而且不會猜錯。
    誤差為 0 時直接跳出,那代表這一塊本來就是 DXT1 解出來的,原封不動編回去。
    """
    # 透明度只有「有」跟「沒有」兩種,門檻取 128。DXT1 表達不了半透明。
    vis = [i for i in range(16) if alphas[i] >= 128]
    need3 = len(vis) < 16                       # 有透明像素就只能用三色模式
    if not vis:
        # 整塊都透明。照樣把顏色編進去,因為遊戲做雙線性過濾會採樣到透明像素的
        # 顏色,填黑會在邊緣暈出黑邊。
        vis = list(range(16)); need3 = True
    cols = sorted({px[i] for i in vis})

    # 兩個端點換算成 565 之後可能會撞成同一個值,那時候調色盤退化成單色,
    # 沒有模式可選(modes = [None]),整塊就填那一個顏色。
    best_err = None; best = None
    for a, b in _endpoint_candidates(cols):
        ca, cb = _to565(*a), _to565(*b)
        if ca == cb:
            modes = [None]
        elif need3:
            modes = [False]
        else:
            modes = [True, False]
        for four in modes:
            if four is None:
                c0 = c1 = ca
                lookup = 0
                if need3:
                    for k in range(16):
                        if alphas[k] < 128:
                            lookup |= 3 << (2 * k)
                err = sum(_dist(px[i], _from565(c0)) for i in vis)
            else:
                c0, c1 = (max(ca, cb), min(ca, cb)) if four else (min(ca, cb), max(ca, cb))
                pal = _palette(c0, c1, four)
                hi = 3 if four else 2            # 三色模式的索引 3 是透明,不能拿來配色
                lookup = 0; err = 0
                for k in range(16):
                    if need3 and alphas[k] < 128:
                        lookup |= 3 << (2 * k)
                        continue
                    bi = 0; bd = None
                    for pi in range(hi + 1):
                        d = _dist(px[k], pal[pi])
                        if bd is None or d < bd:
                            bd, bi = d, pi
                    if alphas[k] >= 128:
                        err += bd
                    lookup |= bi << (2 * k)
            if best_err is None or err < best_err:
                best_err, best = err, (c0, c1, lookup)
                if err == 0:
                    break
        if best_err == 0:
            break
    c0, c1, lookup = best
    return bytes((c0 & 0xFF, c0 >> 8, c1 & 0xFF, c1 >> 8,
                  lookup & 0xFF, (lookup >> 8) & 0xFF,
                  (lookup >> 16) & 0xFF, (lookup >> 24) & 0xFF))


# ─────────────────────────────────────────────────────────
#  PNG:自己讀、自己寫(只用內建的 zlib,不需要安裝任何套件)
# ─────────────────────────────────────────────────────────
PNG_MAGIC = b'\x89PNG\r\n\x1a\n'


def png_write(path, rgba, w, h):
    """寫一張 8 位元 RGBA 的 PNG。只用內建的 zlib,不需要裝 Pillow。

    PNG 的每個區塊都是「4 個位元組長度 + 4 個位元組標籤 + 內容 + 4 個位元組 CRC」,
    長度與 CRC 都是 big-endian。這裡只寫最少的三塊:IHDR、IDAT、IEND。
    IHDR 那串 8, 6, 0, 0, 0 的意思是:每色 8 位元、色彩型別 6(RGBA)、
    標準壓縮、標準濾波、不交錯。
    """
    def chunk(tag, payload):
        return (struct.pack('>I', len(payload)) + tag + payload +
                struct.pack('>I', zlib.crc32(tag + payload) & 0xFFFFFFFF))
    # 每一列前面都要有一個「濾波型別」位元組。這裡固定選 0(不做預測),
    # 因為這張圖是要拿給人編輯的:壓得小不重要,寫得單純、不會出錯才重要。
    rows = bytearray()
    for y in range(h):
        rows.append(0)                                  # 每一列前面加一個 0 = 不做預測濾波
        rows += rgba[y * w * 4:(y + 1) * w * 4]
    data = (PNG_MAGIC
            + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0))
            + chunk(b'IDAT', zlib.compress(bytes(rows), 9))
            + chunk(b'IEND', b''))
    # 2026-09-05:原本是 open(path, 'wb') 直接寫。那有兩個問題 ——
    # 檔名是使用者打的、可預測,而且 'wb' 會先把既有的那個檔截成 0;
    # 寫到一半失敗(磁碟滿)就留下一張半截 PNG 佔著那個名字。
    # 改成寫在同資料夾的 mkstemp 暫存檔,fsync 完才 os.replace 換上。
    fd, tmp = _new_temp_beside(path, '.png-')
    try:
        with os.fdopen(fd, 'wb') as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())
        os.chmod(tmp, 0o644)           # mkstemp 給的是 0600,PNG 是要拿去給人看的
        os.replace(tmp, path)
    except BaseException:
        try:
            os.remove(tmp)
        except OSError:
            pass
        raise


def png_read(path):
    """回傳 (rgba bytes, w, h)。支援 8 位元的灰階/RGB/索引/灰階+透明/RGBA,不支援交錯。"""
    # 2026-09-05:這兩個是這一課最常見的兩種輸入錯誤,原本會吐 Python
    # traceback(FileNotFoundError / zlib.error)。讀者是「會用修圖軟體」的人,
    # 不是會讀 traceback 的人。兩者都發生在任何寫入之前,檔案不會被弄壞。
    try:
        with open(path, 'rb') as f:
            data = f.read()
    except OSError as e:
        raise DataError('讀不到 %s(%s)—— 檔名打對了嗎?'
                        '路徑裡有空白或中文的話,整個路徑要用引號包起來'
                        % (path, e.strerror or e))
    if data[:8] != PNG_MAGIC:
        raise DataError('%s 不是 PNG 檔' % os.path.basename(path))
    pos = 8
    w = h = depth = ctype = None
    idat = bytearray(); plte = None; trns = None
    while pos + 8 <= len(data):
        ln = struct.unpack_from('>I', data, pos)[0]
        tag = data[pos + 4:pos + 8]
        body = data[pos + 8:pos + 8 + ln]
        pos += 12 + ln
        if tag == b'IHDR':
            w, h, depth, ctype, comp, filt, inter = struct.unpack('>IIBBBBB', body)
            if depth != 8:
                raise DataError('只支援每色 8 位元的 PNG,這張是 %d 位元' % depth)
            if inter:
                raise DataError('不支援交錯式(interlaced)PNG,請另存成一般的 PNG')
        elif tag == b'PLTE':
            plte = body
        elif tag == b'tRNS':
            trns = body
        elif tag == b'IDAT':
            idat += body
        elif tag == b'IEND':
            break
    if w is None:
        raise DataError('這個 PNG 沒有 IHDR')
    channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(ctype)
    if channels is None:
        raise DataError('沒見過的 PNG 色彩型別 %d' % ctype)

    # IDAT 可能被切成好幾塊,要全部接起來再一次解壓。
    # 解出來不是純像素,而是「每列一個濾波型別位元組 + 一整列像素」。
    try:
        raw = zlib.decompress(bytes(idat))
    except zlib.error as e:
        raise DataError('%s 的影像資料解不開(%s)—— 這張 PNG 多半是半截的'
                        '(下載沒完成、或複製到一半被中斷)。請用修圖軟體重新存一次'
                        % (os.path.basename(path), e))
    stride = w * channels
    out = bytearray(h * stride)
    prev = bytearray(stride)
    p = 0
    for y in range(h):
        ft = raw[p]; p += 1
        line = bytearray(raw[p:p + stride]); p += stride
        # PNG 的五種預測濾波,每一列自己選一種
        if ft == 1:
            for i in range(channels, stride):
                line[i] = (line[i] + line[i - channels]) & 0xFF
        elif ft == 2:
            for i in range(stride):
                line[i] = (line[i] + prev[i]) & 0xFF
        elif ft == 3:
            for i in range(stride):
                a = line[i - channels] if i >= channels else 0
                line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
        elif ft == 4:
            for i in range(stride):
                a = line[i - channels] if i >= channels else 0
                b = prev[i]
                c = prev[i - channels] if i >= channels else 0
                pa = abs(b - c); pb = abs(a - c); pc = abs(a + b - 2 * c)
                pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
                line[i] = (line[i] + pr) & 0xFF
        elif ft != 0:
            raise DataError('沒見過的 PNG 濾波型別 %d' % ft)
        out[y * stride:(y + 1) * stride] = line
        prev = line

    # 不管來源是哪一種色彩型別,一律攤平成「一個像素 4 個位元組」的 RGBA,
    # 後面的編碼器就只要認得這一種排法。
    rgba = bytearray(w * h * 4)
    for i in range(w * h):
        s = i * channels; d = i * 4
        if ctype == 6:
            rgba[d:d + 4] = out[s:s + 4]
        elif ctype == 2:
            rgba[d:d + 3] = out[s:s + 3]; rgba[d + 3] = 255
        elif ctype == 0:
            v = out[s]; rgba[d] = rgba[d + 1] = rgba[d + 2] = v; rgba[d + 3] = 255
        elif ctype == 4:
            v = out[s]; rgba[d] = rgba[d + 1] = rgba[d + 2] = v; rgba[d + 3] = out[s + 1]
        else:                                            # 索引色
            if plte is None:
                raise DataError('索引色 PNG 卻沒有調色盤')
            idx = out[s]
            rgba[d:d + 3] = plte[idx * 3:idx * 3 + 3]
            rgba[d + 3] = trns[idx] if (trns and idx < len(trns)) else 255
    return bytes(rgba), w, h

# ─────────────────────────────────────────────────────────
#  三個畫面各自住在哪
#
#  ⚠️ 這張表是量出來的,不是抄別人的說明書。
#     2007 年有一份社群工具的說明寫「開機畫面是遊戲根目錄的
#     00000000.256」—— 在本站測的這台機器上,整個遊戲目錄
#     一個 .256 都沒有,執行檔裡也找不到那個字串。
#     真正的線索在執行檔的符號表(FSPLASH / LEGAL / LOADING)
#     跟版面檔 fes_title.fel 裡:它寫 SH:CLICKME,...,lice,lice,
#     而 license.fsh 裡那張圖的名字就叫 lice。名字對上名字,
#     不是數量對上數量。
# ─────────────────────────────────────────────────────────
# 每一項是 (封裝檔的相對路徑, 封裝檔裡那一項的名字, 給人看的說明)。
# 路徑寫成斜線,用的時候才 split 出來交給 os.path.join,Windows 上一樣走得到。
SCREENS = {
    'splash':  ('data/frontend/splash.big',   'splash.fsh',
                '開機大圖(遊戲啟動時第一張)'),
    'title':   ('data/frontend/sushared.big', 'license.fsh',
                '標題頁 / 授權畫面用的圖'),
    'loading': ('data/frontend/suonly.big',   'memcard.fsh',
                '讀取中顯示的圖'),
}


def locate(gamedir, key):
    """回傳 (big 路徑, 這一項在目錄裡的欄位位置, 名稱, 資料 offset, 長度)。

    ⚠️ big_entries 回的是四元組 (名稱, 目錄欄位位置, offset, 長度)。
       欄位位置要留著,append_entry 只改那 8 個位元組。
    """
    rel, member, _ = SCREENS[key]
    path = os.path.join(gamedir, *rel.split('/'))
    if not os.path.isfile(path):
        raise DataError('找不到 %s —— 這個遊戲資料夾對嗎?' % rel)
    for name, field, off, size in big_entries(path):
        if name == member:
            return path, field, name, off, size
    raise DataError('%s 裡面沒有 %s' % (rel, member))


def decode_screen(gamedir, key):
    """回傳 (big 路徑, field_pos, 名稱, 解開後的 SHPI, 格式, 寬, 高, 像素起, 像素迄)

    --info、--export、--import 三個動作共用這一段:找到位置、把那一項讀出來、
    開頭兩個位元組是 10 FB 才解壓(不是就當它本來就沒壓),
    再從 SHPI 裡把第一張圖的規格量出來。
    三條路走同一段,所以你在預覽看到的規格,就是真的寫入時用的那一份。
    """
    path, field_pos, name, off, size = locate(gamedir, key)
    raw, code, w, h, s, e = _decode_at(path, off, size)
    return path, field_pos, name, raw, code, w, h, s, e


def _decode_at(path, off, size):
    """照 (offset, 長度) 把那一項讀出來、解開、量規格,回傳 (SHPI, 格式, 寬, 高, 像素起, 像素迄)。

    decode_screen(讀遊戲檔)與 _check_work_copy(讀工作複本)走同一段:
    換名之前驗的,跟遊戲之後讀的,是同一套解析。
    """
    blob = read_entry(path, off, size)
    raw = qfs_decompress(blob) if blob[:2] == b'\x10\xfb' else blob
    code, w, h, s, e = fsh_first_image(raw)
    return raw, code, w, h, s, e


def to_rgba(code, w, h, px):
    """把檔案裡的像素轉成統一的 RGBA,好交給 png_write。

    0x7D 這種未壓縮的圖,檔案裡的順序是 BGRA 不是 RGBA。
    這件事看錯的話畫面不會壞掉,只會紅藍對調,
    而且你多半會以為是自己那張圖有問題。
    """
    if code == 0x60:
        return dxt1_decode(px, w, h)
    if code == 0x7D:                       # 未壓縮 32 位元,檔案裡是 BGRA
        out = bytearray(w * h * 4)
        for i in range(w * h):
            b, g, r, a = px[i * 4:i * 4 + 4]
            out[i * 4:i * 4 + 4] = bytes((r, g, b, a))
        return bytes(out)
    raise DataError('這個畫面是 %s 格式,本工具目前只處理 DXT1 與 32 位元'
                    % FSH_FORMATS.get(code, '0x%02X' % code))


def from_rgba(code, w, h, rgba):
    """to_rgba 的反向。寫回去一定要用原本那一種格式,不能順便換格式。

    格式代號是從檔案裡讀出來的,不是使用者選的:換格式會連動到記錄裡的
    長度欄位與後面每一張圖的位移,那已經不是「換一張圖」的範圍了。
    """
    if code == 0x60:
        return dxt1_encode(rgba, w, h)
    if code == 0x7D:
        out = bytearray(w * h * 4)
        for i in range(w * h):
            r, g, b, a = rgba[i * 4:i * 4 + 4]
            out[i * 4:i * 4 + 4] = bytes((b, g, r, a))
        return bytes(out)
    raise DataError('不支援寫入 0x%02X' % code)


def cmd_info(gamedir):
    """--info:三個畫面各印一行。唯讀,連暫存檔都不會產生。

    某一個畫面讀不出來不影響另外兩個:每一個各自 try,壞掉的那個印警告。
    社群模組常常只換其中一張,一張壞掉不該讓另外兩張也看不到。
    """
    print('\n  代號       格式        尺寸          封裝檔')
    print('  ' + '-' * 66)
    for key in SCREENS:
        rel, member, desc = SCREENS[key]
        try:
            _, _, _, _, code, w, h, s, e = decode_screen(gamedir, key)
            fmt = FSH_FORMATS.get(code, '0x%02X' % code)
            print('  %-9s %-11s %5d x %-5d %s' % (key, fmt, w, h, member))
            print('  %-9s %s' % ('', desc))
        except DataError as err:
            print('  %-9s ⚠ %s' % (key, err))
    print('\n  匯出:--export <代號> <輸出.png>')
    print('  匯入:--import <代號> <你的.png>   (加 --apply 才真的寫)\n')
    return 0


def _refuse_to_clobber(out):
    """--export 的輸出路徑守門員。

    ⚠️ --export 這條路刻意不做備份(它本來就不該碰遊戲檔),所以它絕對不可以
       蓋掉一個「不是 PNG」的既有檔案 —— 蓋掉了沒有任何東西救得回來,
       連 --restore 都不行,因為這條路根本沒留 .screenbak。
    2026-09-05 實測(在複本上):把輸出檔名打成遊戲檔,
    4,194,592 bytes 的 sushared.big 當場變成 594,955 bytes 的 PNG,
    而且印「成功」、exit code 0、資料夾裡一個備份都沒有。
    判斷用**內容**不用副檔名:副檔名是使用者自己打的,騙得了人;
    開頭那 8 個位元組是檔案自己的,騙不了。
    """
    if os.path.isdir(out):
        raise DataError('%s 是一個資料夾,不是檔名 —— 請給一個 .png 結尾的檔名' % out)
    parent = os.path.dirname(os.path.abspath(out))
    if not os.path.isdir(parent):
        raise DataError('%s 這個資料夾不存在 —— 請先把它建好,或換一個路徑' % parent)
    # ⚠️ 2026-09-05 補:下面那個 os.path.exists 對「指到不存在的檔」的符號連結
    #    會回 False,於是這一整道守門員直接被繞過,而 open(out, 'wb') 會穿過
    #    連結,在資料夾外面把那個檔生出來/蓋掉。所以符號連結一律先擋。
    why = _symlink_complaint(out, '匯出的輸出路徑')
    if why:
        raise DataError(why)
    if os.path.exists(out) and os.path.getsize(out) > 0:
        with open(out, 'rb') as f:
            head = f.read(8)
        if head != PNG_MAGIC:
            raise DataError(
                '%s 已經存在,而且它不是 PNG(開頭是 %r)—— 本工具不覆蓋它。\n'
                '     匯出請另外取一個 .png 的檔名。這一道是為了擋'
                '「不小心把輸出檔名打成遊戲檔」:--export 不做備份,蓋掉就沒了'
                % (out, head[:4]))


def cmd_export(gamedir, key, out):
    """--export:把畫面存成 PNG。遊戲檔唯讀,只會產生你指定的那一個 PNG。

    改圖請一律從這張匯出的圖開始改,不要自己開新檔:
    尺寸與格式都是從遊戲檔量出來的,自己開的很容易差幾個像素,匯入時就被擋。

    ⚠️「唯讀」是對**遊戲檔**說的,不是對你給的那個輸出路徑說的 —— 那個路徑
       本來就是要被寫出去的。所以寫之前先過 _refuse_to_clobber 這一道。
    """
    _, _, name, raw, code, w, h, s, e = decode_screen(gamedir, key)
    _refuse_to_clobber(out)
    rgba = to_rgba(code, w, h, raw[s:e])
    try:
        png_write(out, rgba, w, h)
    except OSError as err:
        raise DataError('寫不出 %s(%s)—— 那個位置可能是唯讀的,或磁碟滿了'
                        % (out, err.strerror or err))
    print('  %s(%s %dx%d)→ %s' % (name, FSH_FORMATS.get(code), w, h, out))
    print('  改完之後用 --import %s "%s" 放回去。尺寸不能變。' % (key, out))
    return 0


def cmd_import(gamedir, key, src, apply_it):
    """--import:把你的 PNG 換進去。沒有 --apply 就只印預覽。

    順序是刻意排的:先把原本那張解出來(拿到格式、尺寸、像素範圍),
    再讀你的 PNG,尺寸不合就在這裡停住。
    所有會失敗的檢查全部排在備份與寫入之前,所以失敗的時候檔案還沒被碰過。

    ⚠️ 2026-09-05 訂正:上面那句話原本是假的。「封裝檔頭大小欄位對不上」
       那一道埋在 append_entry 裡面,排在備份**後面** —— 於是一個已經寫壞的
       封裝檔會先被備份成 .screenbak(而備份只做第一次,那份壞的就固定下來了),
       才在下一行被擋掉。現在那一道搬到備份之前,這句話才是真的。
    """
    path, field_pos, name, raw, code, w, h, s, e = decode_screen(gamedir, key)
    rgba, nw, nh = png_read(src)
    if (nw, nh) != (w, h):
        raise DataError('你的圖是 %dx%d,這個畫面要 %dx%d —— 拒絕寫入,本工具不幫你縮放'
                        % (nw, nh, w, h))
    px = from_rgba(code, w, h, rgba)
    new_raw = fsh_replace_pixels(raw, s, e, px)
    packed = qfs_compress_literal(new_raw)
    print('  目標   %s → %s(%s %dx%d)' % (os.path.basename(path), name,
                                          FSH_FORMATS.get(code), w, h))
    print('  來源   %s' % src)
    print('  寫入後 %s 這一項會變成 %s bytes' % (name, format(len(packed), ',')))
    if not apply_it:
        print('\n  這是預覽,沒有動到任何檔案。確定要寫就加 --apply\n')
        return 0
    # 這一道要在備份之前跑:封裝檔本身已經壞掉的話,備份它只會把壞的那份定住。
    # 順便給一句指路 —— 原本的訊息只說「這個檔可能已經損毀」,沒說下一步。
    try:
        size_field_order(path)
    except DataError as err:
        raise DataError(
            '%s\n'
            '     這個封裝檔已經被動過或寫壞了,本工具不敢在上面接東西。\n'
            '     如果你之前用本工具改過它,現在跑 --restore 就能還原;\n'
            '     沒有本工具的備份就從你自己那份完整備份複製一個回來。' % err)
    # 備份只做第一次。第二次以後要保留最早那一份,
    # 不然改第二次就會拿「已經改過的檔」當備份,--restore 就還原不回原版了。
    bak = path + BACKUP_SUFFIX
    # ⚠️ 這裡要用 lexists 不是 exists(2026-09-05 補):備份檔的名字是猜得到的,
    #    有人先在那裡放一個指到別處的符號連結時,exists() 會說「不存在」
    #    (連結的另一頭不存在就回 False),於是備份會穿過連結寫到資料夾外面去。
    #    _atomic_copy 自己也擋一次,這裡先擋是為了讓訊息出現在動任何東西之前。
    why = _symlink_complaint(bak, '備份檔')
    if why:
        raise DataError(why)
    # 封裝檔本身是符號連結的話,先講這件事,不要先講空間:空間檢查會穿過連結去量
    # 另一頭,低空間時讀者會先看到「硬碟空間不夠」而不是真正的原因(2026-10-30)。
    # _atomic_copy / _stage_copy / _swap_in 裡各自還有一道,那幾道是擋競態用的。
    why = _symlink_complaint(path, '要改的封裝檔')
    if why:
        raise DataError(why)
    # 複本路線的代價:硬碟要同時放得下工作複本(第一次還有備份)。不夠就在這裡停,
    # 那時候遊戲檔與備份都還沒被碰過(2026-10-29)。
    need_backup = not os.path.lexists(bak)
    _require_space(path, need_backup, len(packed))
    work = None
    made_backup = False
    try:
        if need_backup:
            _atomic_copy(path, bak)
            made_backup = True
            print('  已備份 → %s' % os.path.basename(bak))
        # ── 從這裡開始,改動全部做在工作複本上;到 _swap_in 為止遊戲檔一個位元組都沒被寫過 ──
        print('  正在準備工作複本(%s)...' % _mb(os.path.getsize(path)))
        work = _stage_copy(path)
        append_entry(work, field_pos, packed)
        _check_work_copy(work, name, new_raw)
        print('  工作複本驗過了,換上去')
        _swap_in(work, path)
        work = None
    except OSError as err:
        # 遊戲裝在 Program Files、或整包從光碟複製過來的,常常整個資料夾是唯讀的。
        # 原本這裡會吐 PermissionError 的 traceback,而且留下一個孤兒備份
        # 沒人告訴使用者它是好的還是壞的。
        # ⚠️ 2026-10-29:原本這裡固定說「遊戲檔一個位元組都沒有動」。那是因為以前寫入
        #    是對遊戲檔接資料,寫一半出事(磁碟滿)時那句話是假的。現在寫的是複本,
        #    正常情況下那句話是真的 —— 但不再憑感覺講,照 _TOUCHED(換名之後才登記)講。
        # 先刪、再問磁碟刪掉了沒,才決定怎麼說:外接碟被拔掉的時候正好刪不掉。
        note = None
        if work is not None:
            _drop(work)
            note = _leftover_note(work, path)
            work = None
        base = os.path.basename(path)
        bak_here = _presence(bak)           # 不用 lexists:碟不見的時候它會說「沒有」
        if bak_here:
            bak_state = ('已經做好了,而且是完整的(%s)' if made_backup
                         else '是先前就有的(%s),這次沒有重做') % os.path.basename(bak)
        elif bak_here is False:
            bak_state = '也還沒做成'
        else:
            bak_state = '在不在現在也看不到(那顆碟讀不到)'
        extra = ('\n     ' + note.replace('\n', '\n     ')) if note else ''
        if _is_unsure(path):
            # 換名出事之後問磁碟也分不出換上去了沒(碟讀不到、或檔案被別的程式動過):只講實話,不猜。
            raise DataError(
                '寫入途中出事了(%s),而且**沒辦法確認** %s 有沒有已經換成新的。\n'
                '     換名那一刻出了狀況,事後問磁碟也分不出新舊(外接碟被拔掉,\n'
                '     或複本、遊戲檔被防毒軟體之類的程式動過,都會這樣)。\n'
                '     先別急著還原:碟接好之後,用下面這行把這個畫面匯出來看一眼:\n'
                '       python3 -I %s "%s" --export %s 目前.png\n'
                '     是你的新圖 = 已經換成功了,不必做任何事;還是原來那張 = 沒換成,直接再跑一次同一行;\n'
                '     打不開、或看起來是壞的,才跑 --restore 還原。\n'
                '     備份%s。%s'
                % (err.strerror or err, base, os.path.basename(__file__), gamedir, key,
                   bak_state, extra))
        if path in _TOUCHED:
            raise DataError(
                '寫入途中出事了(%s),而且 %s **已經被動到了**,現在可能是壞的。\n'
                '     請立刻還原:python3 -I %s "%s" --restore\n'
                '     備份%s。%s'
                % (err.strerror or err, base, os.path.basename(__file__), gamedir,
                   bak_state, extra))
        raise DataError(
            '寫不進去(%s)。\n'
            '     常見原因:%s 或它所在的資料夾是唯讀的、遊戲還開著、硬碟空間不夠、外接碟被拔掉。\n'
            '     遊戲檔一個位元組都沒有動;備份%s。%s\n'
            '     改成可以寫再跑一次:Windows 右鍵→內容→取消「唯讀」;'
            'macOS / Linux 用 chmod u+w'
            % (err.strerror or err, base, bak_state, extra))
    finally:
        # 沒換上去的工作複本一律清掉。問的是磁碟:換名成功之後 work 這個名字就不存在了
        # (Ctrl-C 剛好落在換名之後、work = None 之前也一樣),不會誤刪。
        if work is not None:
            _drop(work)
            note = _leftover_note(work, path)
            if note:
                print('  ⚠ ' + note)
    _verify_after_write(gamedir, key, new_raw, path, bak)
    print('  ✅ 寫好了。要還原就跑 --restore\n')
    return 0


def _check_work_copy(work, member, expect_raw):
    """換上去之前,把工作複本照目錄表讀回來驗一次,沒過就丟 DataError(**不換上去**)。

    走的跟 _verify_after_write 同一套解析(big_entries → read_entry → 解壓 → SHPI),
    再多看一件事:檔頭「總大小」那一欄要等於複本的實際大小(append_entry 有沒有寫完)。
    讀不動(自己剛寫出去的東西自己讀不回來)跟內容對不上一樣算沒過。
    """
    try:
        size_field_order(work)
        raw = None
        for name, field, off, size in big_entries(work):
            if name == member:
                raw = _decode_at(work, off, size)[0]
                break
        if raw is None:
            why = '工作複本的目錄裡找不到 %s' % member
        elif raw != expect_raw:
            why = '讀回來的內容跟要寫進去的不一樣'
        else:
            return
    except (DataError, OSError) as err:
        why = str(getattr(err, 'strerror', None) or err)
    raise DataError('換上去之前先驗工作複本,沒有過:%s。\n'
                    '     所以**沒有換上去**,遊戲檔一個位元組都沒有動,不必 --restore。\n'
                    '     請把這段訊息回報。' % why)


def _verify_after_write(gamedir, key, expect_raw, path, bak):
    """寫完之後照著目錄表把那一項讀回來,對不上就自動還原,而且不會說成功。

    ── 2026-09-05 加(上線前資安稽核)──────────────────────────────────
    在這之前,append_entry 沒丟例外就直接印「✅ 寫好了」,沒有任何一行去確認
    「目錄現在指到的那段資料,解出來真的是我要寫的東西」。而這一支改的是**目錄
    指標**:寫錯的話舊資料其實還在檔案裡,只是沒人指得到它 —— 這種壞法從檔案
    大小、從 exit code 都看不出來,只有讀回來比對才看得到。

    這一趟是真的從磁碟重讀(locate → big_entries → read_entry → 解壓),
    走的跟遊戲讀它的路徑一樣,不是拿記憶體裡的變數自己比自己。

    對不上就用備份自動還原(這一步是安全的:備份是完整的,還原是原子的),
    然後 raise 讓 exit code 變 1。**絕對不可以只印一個 ❌ 然後 return 0** ——
    照著指令做的人不會去讀輸出,他只知道跑完了。
    """
    try:
        raw = decode_screen(gamedir, key)[3]
        bad = (raw != expect_raw)
        why = '讀回來的內容跟要寫進去的不一樣'
    except (DataError, OSError) as err:
        # OSError 也算「讀不回來」:這一步發生在換名之後,遊戲檔已經是新的了,
        # 不接的話會變成一頁 traceback,連「可以用備份還原」都沒人告訴讀者。
        bad, why = True, str(getattr(err, 'strerror', None) or err)
    if not bad:
        return
    msg = '寫完之後讀回來複驗沒過:%s —— 這一次寫入不算成功。' % why
    if os.path.lexists(bak):
        try:
            _restore_from_backup(bak, path)
            msg += ('\n     已經用備份把 %s 還原回原本的樣子了,遊戲可以照常開;'
                    '請把問題連同這段訊息回報。' % os.path.basename(path))
        except (SystemExit, OSError) as err:
            msg += ('\n     自動還原也沒成功(%s)。\n'
                    '     請手動跑:--restore;它沒救回來的話,'
                    '從你自己那份完整備份把 %s 複製回來。'
                    % (err, os.path.basename(path)))
    else:
        msg += ('\n     而且找不到備份 %s —— 請從你自己那份完整備份把 %s 複製回來。'
                % (os.path.basename(bak), os.path.basename(path)))
    raise DataError(msg)


def cmd_restore(gamedir):
    """--restore:三個封裝檔,只要有本工具留下的備份就還原。

    只認 .screenbak 這一個副檔名,不會去碰別課或別人留下的 .bak。
    每一份都要先過 _restore_from_backup 那幾道檢查,半截的備份不敢拿來用。

    ⚠️ 一份壞掉不可以連累另外兩份(2026-09-05 修)。
       _restore_from_backup 的守門是 raise SystemExit,而這個迴圈原本沒有接,
       SCREENS 的走訪順序又是 splash → title → loading —— 排在前面那份
       .screenbak 壞掉,後面兩份好的備份就永遠用不到。實測:三份備份只有
       splash 那份是半截的,結果三個封裝檔一個都沒還原。
       而人會跑 --restore 的時機,正是遊戲已經開不起來的時候:
       這時候能救幾個就救幾個,救不動的最後單獨列出來並說下一步。

    回傳值:全部還原成功是 0;只要有任何一份沒成功就是 1,
    好讓「有沒有全部救回來」在 exit code 上看得出來,不必去讀輸出。
    """
    n = 0
    bad = []
    for key in SCREENS:
        rel = SCREENS[key][0]
        path = os.path.join(gamedir, *rel.split('/'))
        bak = path + BACKUP_SUFFIX
        # lexists 不是 exists:備份檔的名字被一條指到別處的符號連結佔住時,
        # exists() 回 False 會讓這一份被靜靜跳過,使用者只看到「沒有找到備份」。
        # 用 lexists 走進去,由 _restore_from_backup 把真正的原因說出來。
        if not os.path.lexists(bak):
            continue
        try:
            _restore_from_backup(bak, path)
        except SystemExit as err:
            # 備份沒過檢查(半截、0 bytes、檔頭對不上),或還原後複驗對不上。
            bad.append((os.path.basename(path), str(err)))
            continue
        except OSError as err:
            # 目標檔或資料夾是唯讀的。原本這裡會吐 PermissionError 的 traceback。
            bad.append((os.path.basename(path),
                        '寫不進去(%s)。這個檔或它所在的資料夾是唯讀的 —— '
                        'Windows 右鍵→內容→取消「唯讀」,'
                        'macOS / Linux 用 chmod u+w,再跑一次。'
                        % (err.strerror or err)))
            continue
        print('  還原 %s' % os.path.basename(path))
        n += 1
    # ⚠️ 「一個都沒還原」有兩種完全不同的意思,不可以印同一句話:
    #    真的沒有備份 vs 備份找到了但全部沒過檢查。後者印「沒有找到備份」
    #    會讓人以為自己沒備份過,那是假的。
    if n:
        print('  共還原 %d 個檔' % n)
    elif bad:
        print('  一個都沒還原成功')
    else:
        print('  沒有找到本工具做的備份')
    for name, why in bad:
        print('\n  ❌ %s 沒有還原成功:\n     %s' % (name, why.replace('\n', '\n     ')))
    if bad:
        # ⚠️ 這一句不可以寫死成「請改用你自己的備份」—— 上面兩種失敗的下一步不一樣:
        #    備份壞掉才需要換一份備份,唯讀寫不進去要做的是把檔案改成可寫。
        #    所以這裡只說「照它自己那一行做」,細節由上面各自那一行負責。
        print('\n  ⚠ 上面這 %d 個沒有還原成功,請照它各自那一行寫的處理;'
              '能還原的已經還原好了。\n'
              '    備份是壞的那幾個,把那個 %s 移開再跑一次,本工具就不會再碰它。\n'
              % (len(bad), BACKUP_SUFFIX))
        return 1
    print('')
    return 0


# ─────────────────────────────────────────────────────────
#  --selftest:不需要遊戲檔,只驗那幾道會決定「玩家的檔會不會壞」的守門員
# ─────────────────────────────────────────────────────────
def selftest():
    """替守門員配**餌**:先製造出它應該擋下來的那個情況,再看它有沒有擋。

    ⚠️ 為什麼一定要下餌:一個永遠不會亮的檢查,跟一個好的檢查,在測試報告上
       長得一模一樣(全綠)。所以每一組都成對出現 —— 先一個「正常流程真的做得成」
       的陰性對照,再一個「該擋的真的被擋住,而且旁邊那個檔一個位元組都沒被動到」。

    這裡驗的都是**檔案安全**那幾道,不驗 DXT1/QFS 那些格式細節 ——
    那些要有真的遊戲檔才驗得動(見檔頭「做不到的事」)。

    ⚠️ 檔案安全那幾道也不是每一道都配到餌。2026-09-24 把這一段以外、條件成立就讓
       這支停下來的 43 道守門逐一拆掉(那一個 if 改成永遠不成立)再跑一次,
       會變紅的 6 道全在檔案安全那一類;那一類(備份、還原、符號連結、輸出路徑)
       一共 15 道,另外 9 道拆掉照樣全綠,例如備份寫完讀回來對不上、
       還原的暫存檔長度對不上、匯出路徑是一個資料夾。其餘 28 道是格式與參數的守門。
       (以上是 2026-09-24 那一版的數字。2026-10-29 新加的第 13 組「--import --apply
       走工作複本」另外量過:把它的守門用 17 種拆法逐一拆掉再跑,17 種全部變紅。
       2026-10-30 補的餌⑭~⑳(換名出事後分「確認換上 / 確認沒換上 / 沒辦法確認」三種、
       預檢量不到大小、符號連結先於空間)也量過:新加的守門用 26 種拆法逐一拆掉,26 種全部變紅。
       2026-10-30.2 補的還原餌⑥~⑪(--restore 的換名也先登記、出事問磁碟)同樣量過:
       用 12 種拆法逐一拆掉(含退回舊做法「換完才登記」),12 種全部變紅,每一種都有新加的還原餌跟著紅。)
    """
    import random
    fails = []
    passes = [0]

    def check(name, ok, detail=''):
        if ok:
            passes[0] += 1
            print('  ✓ %s' % name)
        else:
            fails.append(name)
            print('  ✗ %s  %s' % (name, detail))

    def blob(n, seed):
        r = random.Random(seed)
        return bytes(r.getrandbits(8) for _ in range(n))

    root = tempfile.mkdtemp(prefix='mvp_swap_screen_selftest_')
    outside_dir = os.path.join(root, 'outside')
    work = os.path.join(root, 'work')
    os.makedirs(outside_dir)
    os.makedirs(work)
    OUT_SENTINEL = b'OUTSIDE-FILE-MUST-NOT-BE-TOUCHED\n' * 64

    def fresh_outside(name):
        pth = os.path.join(outside_dir, name)
        with open(pth, 'wb') as f:
            f.write(OUT_SENTINEL)
        return pth

    def outside_intact(pth):
        with open(pth, 'rb') as f:
            return f.read() == OUT_SENTINEL

    def leftovers(d, keep):
        return sorted(x for x in os.listdir(d) if x not in keep)

    def write(pth, data):
        with open(pth, 'wb') as f:
            f.write(data)
        return pth

    try:
        # ── 1. _atomic_copy:陰性對照(先證明正常流程真的做得成)────────────
        d1 = os.path.join(work, 'a1'); os.makedirs(d1)
        src = write(os.path.join(d1, 'x.big'), blob(200000, 1))
        bak = src + BACKUP_SUFFIX
        _atomic_copy(src, bak)
        check('備份:正常流程做得出一份逐位元組相同的備份',
              os.path.exists(bak) and _same_bytes(src, bak))
        check('備份:做完之後資料夾裡沒有留下暫存檔',
              leftovers(d1, {'x.big', os.path.basename(bak)}) == [],
              leftovers(d1, {'x.big', os.path.basename(bak)}))

        # ── 2. 餌:有人先佔住「猜得到的暫存檔名」,而且它指到資料夾外面 ──────
        d2 = os.path.join(work, 'a2'); os.makedirs(d2)
        src = write(os.path.join(d2, 'x.big'), blob(120000, 2))
        bak = src + BACKUP_SUFFIX
        victim = fresh_outside('victim_backup_part')
        os.symlink(victim, bak + '.part')           # 舊版寫的就是這個名字
        _atomic_copy(src, bak)
        check('備份餌①:被預先放了 <備份檔>.part 符號連結,資料夾外那個檔沒被動到',
              outside_intact(victim))
        check('備份餌①:備份本身照樣做得成而且是完整的',
              os.path.exists(bak) and _same_bytes(src, bak))

        # ── 3. 餌:備份檔那個名字本身就是一條指到外面的符號連結 ──────────────
        d3 = os.path.join(work, 'a3'); os.makedirs(d3)
        src = write(os.path.join(d3, 'x.big'), blob(50000, 3))
        bak = src + BACKUP_SUFFIX
        victim = fresh_outside('victim_backup_itself')
        os.symlink(victim, bak)
        try:
            _atomic_copy(src, bak)
            check('備份餌②:備份檔是符號連結時要拒絕', False, '沒有拒絕')
        except DataError:
            check('備份餌②:備份檔是符號連結時拒絕,而且外面那個檔沒被動到',
                  outside_intact(victim))

        # ── 4. 餌:dangling 符號連結會騙過 os.path.exists ─────────────────
        dang = os.path.join(work, 'dangling')
        os.symlink(os.path.join(outside_dir, 'no_such_file'), dang)
        check('符號連結:exists() 說「不存在」,但守門員抓得到(這正是舊版被繞過的那一格)',
              (not os.path.exists(dang)) and os.path.lexists(dang)
              and _symlink_complaint(dang, '測試') is not None)

        # ── 5. _do_copy:陰性對照(還原真的做得成、權限有保住)──────────────
        d5 = os.path.join(work, 'r1'); os.makedirs(d5)
        original = blob(300000, 5)
        bak = write(os.path.join(d5, 'x.big' + BACKUP_SUFFIX), original)
        dst = write(os.path.join(d5, 'x.big'), original + blob(4096, 55))
        os.chmod(dst, 0o640)
        del _TOUCHED[:]
        _do_copy(bak, dst)
        check('還原:正常流程真的把正本還原成備份的樣子', _same_bytes(bak, dst))
        check('還原:權限有從正本抄回去(0640)', (os.stat(dst).st_mode & 0o777) == 0o640,
              oct(os.stat(dst).st_mode & 0o777))
        check('還原:做完之後資料夾裡沒有留下暫存檔',
              leftovers(d5, {'x.big', os.path.basename(bak)}) == [],
              leftovers(d5, {'x.big', os.path.basename(bak)}))
        check('還原:成功之後有登記「這個檔動過了」(Ctrl-C 才講得出實話)',
              dst in _TOUCHED)

        # ── 6. 餌:還原寫到一半就斷掉(磁碟滿 / 拔碟 / Ctrl-C 都是這一格)────
        d6 = os.path.join(work, 'r2'); os.makedirs(d6)
        bak = write(os.path.join(d6, 'x.big' + BACKUP_SUFFIX), blob(200000, 6))
        before = blob(200000, 66)
        dst = write(os.path.join(d6, 'x.big'), before)
        real_copyfileobj = shutil.copyfileobj

        def half_then_die(fsrc, fdst, length=0):
            fdst.write(fsrc.read(4096))
            raise OSError(28, 'No space left on device')

        del _TOUCHED[:]
        shutil.copyfileobj = half_then_die
        try:
            _do_copy(bak, dst)
            broke = False
        except (OSError, SystemExit):
            broke = True
        finally:
            shutil.copyfileobj = real_copyfileobj
        with open(dst, 'rb') as f:
            after = f.read()
        check('還原餌①:寫到一半斷掉時會失敗收場', broke)
        check('還原餌①:正本一個位元組都沒被動到(舊版這裡會被截成半截)',
              after == before, '%d bytes' % len(after))
        check('還原餌①:沒有留下暫存檔',
              leftovers(d6, {'x.big', os.path.basename(bak)}) == [],
              leftovers(d6, {'x.big', os.path.basename(bak)}))
        check('還原餌①:失敗就不可以登記成「動過了」', dst not in _TOUCHED)

        # ── 7. 餌:最後那一步 os.replace 自己失敗 ────────────────────────
        d7 = os.path.join(work, 'r3'); os.makedirs(d7)
        bak = write(os.path.join(d7, 'x.big' + BACKUP_SUFFIX), blob(80000, 7))
        before = blob(80000, 77)
        dst = write(os.path.join(d7, 'x.big'), before)
        real_replace = os.replace

        def boom(a, b):
            raise OSError(13, 'Permission denied')

        del _TOUCHED[:]
        os.replace = boom
        try:
            _do_copy(bak, dst)
            broke = False
        except (OSError, SystemExit):
            broke = True
        finally:
            os.replace = real_replace
        with open(dst, 'rb') as f:
            after = f.read()
        check('還原餌②:換檔那一步失敗時會失敗收場', broke)
        check('還原餌②:正本原封不動', after == before)
        check('還原餌②:沒有留下暫存檔',
              leftovers(d7, {'x.big', os.path.basename(bak)}) == [],
              leftovers(d7, {'x.big', os.path.basename(bak)}))
        check('還原餌②:失敗就不可以登記成「動過了」', dst not in _TOUCHED)

        # ── 8. 餌:寫出來的東西跟備份不一樣(複驗那一道)────────────────────
        d8 = os.path.join(work, 'r4'); os.makedirs(d8)
        bak = write(os.path.join(d8, 'x.big' + BACKUP_SUFFIX), blob(90000, 8))
        before = blob(90000, 88)
        dst = write(os.path.join(d8, 'x.big'), before)

        def wrong_bytes(fsrc, fdst, length=0):
            fdst.write(b'\x00' * 90000)

        shutil.copyfileobj = wrong_bytes
        try:
            _do_copy(bak, dst)
            blocked = False
        except SystemExit:
            blocked = True
        finally:
            shutil.copyfileobj = real_copyfileobj
        with open(dst, 'rb') as f:
            after = f.read()
        check('還原餌③:寫出來的內容跟備份對不上時要擋下來', blocked)
        check('還原餌③:擋下來之後正本原封不動', after == before)

        # ── 9. 餌:還原的目標是一條指到資料夾外面的符號連結 ──────────────────
        d9 = os.path.join(work, 'r5'); os.makedirs(d9)
        bak = write(os.path.join(d9, 'x.big' + BACKUP_SUFFIX), blob(1000, 9))
        victim = fresh_outside('victim_restore_target')
        os.symlink(victim, os.path.join(d9, 'x.big'))
        try:
            _do_copy(bak, os.path.join(d9, 'x.big'))
            blocked = False
        except SystemExit:
            blocked = True
        check('還原餌④:目標是符號連結時要拒絕', blocked)
        check('還原餌④:資料夾外那個檔沒被動到', outside_intact(victim))
        # 餌:備份檔的名字被一條「指到不存在的檔」的符號連結佔住。
        # 這一格驗的是**訊息有沒有講對原因** —— exists() 會說「找不到備份」,
        # 那是假的:名字明明被佔住了。講錯原因會讓人往錯的方向找。
        d9b = os.path.join(work, 'r6'); os.makedirs(d9b)
        dst = write(os.path.join(d9b, 'x.big'), blob(1000, 96))
        os.symlink(os.path.join(outside_dir, 'never_created'),
                   os.path.join(d9b, 'x.big' + BACKUP_SUFFIX))
        try:
            _restore_from_backup(os.path.join(d9b, 'x.big' + BACKUP_SUFFIX), dst)
            said = '(沒有拒絕)'
        except SystemExit as err:
            said = str(err)
        check('還原餌⑤:備份檔是 dangling 符號連結時,說的是「符號連結」不是「找不到備份」',
              '符號連結' in said, said.split('\n')[0])

        # ── 10. 半截備份:陰性對照 + 餌(這一道 2026-08-30 就有,一起回歸驗)──
        d10 = os.path.join(work, 'b1'); os.makedirs(d10)
        payload = blob(40000, 10)
        good = b'BIGF' + struct.pack('<I', 8 + len(payload)) + payload
        bak = write(os.path.join(d10, 'x.big' + BACKUP_SUFFIX), good)
        dst = write(os.path.join(d10, 'x.big'), good + b'appended')
        _restore_from_backup(bak, dst)
        check('半截備份:完整的 BIGF 備份還原得動(陰性對照)', _same_bytes(bak, dst))
        write(bak, good[:len(good) // 8])           # 砍成前 1/8
        dst = write(os.path.join(d10, 'x.big'), good + b'appended')
        before = good + b'appended'
        try:
            _restore_from_backup(bak, dst)
            blocked = False
        except SystemExit:
            blocked = True
        with open(dst, 'rb') as f:
            after = f.read()
        check('半截備份餌:被砍成 1/8 的備份要擋下來', blocked)
        check('半截備份餌:擋下來之後正本原封不動', after == before)

        # ── 11. --export 的輸出路徑守門員 ───────────────────────────────
        d11 = os.path.join(work, 'e1'); os.makedirs(d11)
        _refuse_to_clobber(os.path.join(d11, 'new.png'))       # 不存在 → 放行
        png_ok = write(os.path.join(d11, 'old.png'), PNG_MAGIC + b'whatever')
        _refuse_to_clobber(png_ok)                             # 既有 PNG → 放行
        check('匯出:不存在的路徑與既有 PNG 都放行(陰性對照)', True)
        notpng = write(os.path.join(d11, 'game.big'), b'BIGF' + b'\x00' * 100)
        try:
            _refuse_to_clobber(notpng)
            check('匯出餌①:輸出檔名打成遊戲檔要拒絕', False, '沒有拒絕')
        except DataError:
            check('匯出餌①:輸出檔名打成遊戲檔會被拒絕', True)
        dang_out = os.path.join(d11, 'trap.png')
        os.symlink(os.path.join(outside_dir, 'not_created_yet'), dang_out)
        try:
            _refuse_to_clobber(dang_out)
            check('匯出餌②:指到外面的 dangling 符號連結要拒絕(舊版 exists() 會放行)',
                  False, '沒有拒絕')
        except DataError:
            check('匯出餌②:指到外面的 dangling 符號連結會被拒絕', True)

        # ── 12. png_write:原子寫出 + 兩個餌 ────────────────────────────
        d12 = os.path.join(work, 'p1'); os.makedirs(d12)
        rgba = bytes((x * 7 + y * 3) & 0xFF for y in range(4) for x in range(4 * 4))
        out = os.path.join(d12, 'a.png')
        victim = fresh_outside('victim_png_part')
        os.symlink(victim, out + '.part')
        png_write(out, rgba, 4, 4)
        back, bw, bh = png_read(out)
        check('匯出:寫出來的 PNG 讀得回來,而且像素一樣', (back, bw, bh) == (rgba, 4, 4))
        check('匯出餌③:被預先放了 <輸出>.part 符號連結,外面那個檔沒被動到',
              outside_intact(victim))
        out2 = os.path.join(d12, 'b.png')
        victim2 = fresh_outside('victim_png_target')
        os.symlink(victim2, out2)
        png_write(out2, rgba, 4, 4)
        check('匯出餌④:輸出路徑本身是符號連結時,寫的是連結那個名字,不是穿過去寫外面',
              outside_intact(victim2) and not os.path.islink(out2))

        # ── 13. --import --apply 走工作複本(2026-10-29 加,D2-03)──────────────
        # 用 main() 從頭跑到尾,假遊戲資料夾裡的 sushared.big 只有幾 KB。
        # 每一組都是「出事的時候正本一個位元組都沒動、畫面上的話是真的」,
        # 前面有正常流程的陰性對照,後面有一個「關掉工作複本」的陰性對照
        # 證明這些斷言在舊做法下真的會紅。
        import contextlib
        import hashlib
        import io

        def sha_of(pth):
            with open(pth, 'rb') as ff:
                return hashlib.sha256(ff.read()).hexdigest()

        def shpi(w, h, bgra):
            px = bytes(bgra) * (w * h)
            rec = (bytes([0x7D]) + (16 + len(px)).to_bytes(3, 'little')
                   + struct.pack('<HH', w, h) + b'\x00' * 8 + px)
            return (b'SHPI' + struct.pack('<I', 24 + len(rec)) + struct.pack('<I', 1)
                    + b'TEST' + b'lice' + struct.pack('<I', 24) + rec)

        def bigf(entries):
            dir_len = sum(8 + len(n) + 1 for n, _ in entries)
            pos = 16 + dir_len
            ents, body = b'', b''
            for n, d in entries:
                ents += struct.pack('>II', pos + len(body), len(d)) + n.encode('latin-1') + b'\x00'
                body += d
            total = 16 + dir_len + len(body)
            return (b'BIGF' + struct.pack('<I', total) + struct.pack('>II', len(entries), dir_len)
                    + ents + body)

        OTHER = bytes(range(256)) * 16

        def make_game(tag, backup=False):
            gd = os.path.join(work, tag)
            fdir = os.path.join(gd, 'data', 'frontend')
            os.makedirs(fdir)
            bg = os.path.join(fdir, 'sushared.big')
            write(bg, bigf([('other.fsh', OTHER),
                            ('license.fsh', shpi(8, 4, (10, 20, 30, 255)))]))
            if backup:
                shutil.copyfile(bg, bg + BACKUP_SUFFIX)
            return gd, bg

        mine = os.path.join(work, 'mine.png')
        png_write(mine, bytes([200, 40, 40, 255]) * 32, 8, 4)
        KEEP = {'sushared.big', 'sushared.big' + BACKUP_SUFFIX}

        def run_import(gd, *flags):
            del _TOUCHED[:]
            del _UNSURE[:]
            old_argv = sys.argv
            sys.argv = ['mvp_swap_screen.py', gd, '--import', 'title', mine] + list(flags)
            buf = io.StringIO()
            try:
                with contextlib.redirect_stdout(buf):
                    rc = main()
            except Exception as exc:
                # 漏出 traceback = 這一組沒過;在這裡接住變成一個紅燈,不要讓整支自我測試崩掉
                rc = 'traceback(%s)' % type(exc).__name__
            finally:
                sys.argv = old_argv
            return rc, buf.getvalue()

        class Patched(object):
            """暫時換掉幾個函式,離開一定換回來。容器是 dict(本檔全域)或物件(os、shutil)。"""
            def __init__(self, *items):
                self.items = items

            @staticmethod
            def _get(c, n):
                return c[n] if isinstance(c, dict) else getattr(c, n)

            @staticmethod
            def _set(c, n, v):
                if isinstance(c, dict):
                    c[n] = v
                else:
                    setattr(c, n, v)

            def __enter__(self):
                self.real = [(c, n, self._get(c, n)) for c, n, _ in self.items]
                for c, n, f in self.items:
                    self._set(c, n, f)

            def __exit__(self, *exc):
                for c, n, r in self.real:
                    self._set(c, n, r)
                return False

        g = globals()
        real_append = g['append_entry']
        real_replace_all = os.replace

        def partial_then(make_exc):
            """append_entry 的替身:真的把新資料接到檔尾(寫一部分),然後丟例外。"""
            def fake(target, field_pos, blob):
                with open(target, 'r+b') as ff:
                    ff.seek(0, os.SEEK_END)
                    ff.write(blob)
                raise make_exc()
            return fake

        def corrupt_append(target, field_pos, blob):
            """append_entry 的替身:寫完沒丟例外,但新資料裡有一個位元組是壞的。"""
            new_off = real_append(target, field_pos, blob)
            with open(target, 'r+b') as ff:
                ff.seek(new_off + 40)
                one = ff.read(1)
                ff.seek(new_off + 40)
                ff.write(bytes([one[0] ^ 0xFF]))
            return new_off

        def replace_fake(mode, big_path):
            """os.replace 的替身:只對「換到遊戲檔身上」那一次動手腳,備份等其他換名照常。"""
            def fake(src, dst):
                if os.fspath(dst) != big_path:
                    return real_replace_all(src, dst)
                if mode == 'fail_before':
                    raise OSError(13, 'Permission denied')
                if mode == 'ctrl_c_before':
                    raise KeyboardInterrupt()
                real_replace_all(src, dst)
                if mode == 'fail_after':
                    raise OSError(5, 'Input/output error')
                if mode == 'ctrl_c_after':
                    raise KeyboardInterrupt()
            return fake

        def enospc():
            return OSError(28, 'No space left on device')

        def entry_of(bg, nm):
            for en, _f, eo, es in big_entries(bg):
                if en == nm:
                    return eo, es
            return None

        # 13-1. 陰性對照:正常流程真的做得完
        gA, bigA = make_game('i_ok')
        beforeA = sha_of(bigA)
        rc, out = run_import(gA, '--apply')
        check('匯入:正常流程做得完(結束碼 0、遊戲檔換成新的)',
              rc == 0 and sha_of(bigA) != beforeA, '%r %s' % (rc, out[-160:]))
        rgbaN = png_read(mine)[0]
        _, _, _, rawN, codeN, wN, hN, sN, eN = decode_screen(gA, 'title')
        check('匯入:換進去的像素讀得回來,跟你給的 PNG 一致',
              to_rgba(codeN, wN, hN, rawN[sN:eN]) == rgbaN)
        check('匯入:備份就是換之前的那一份(逐位元組相同)',
              os.path.exists(bigA + BACKUP_SUFFIX) and sha_of(bigA + BACKUP_SUFFIX) == beforeA)
        check('匯入:同一個封裝檔裡別的項目原封不動',
              read_entry(bigA, *entry_of(bigA, 'other.fsh')) == OTHER)
        check('匯入:做完之後資料夾裡沒有留下工作複本',
              leftovers(os.path.dirname(bigA), KEEP) == [], leftovers(os.path.dirname(bigA), KEEP))
        check('匯入:換上去之後有登記「這個檔動過了」(Ctrl-C 才講得出實話)', bigA in _TOUCHED)

        # 13-2. 餌①:寫工作複本寫到一半 ENOSPC —— 舊版這裡正本會變大,畫面卻說沒動
        gB, bigB = make_game('i_enospc')
        beforeB, sizeB = sha_of(bigB), os.path.getsize(bigB)
        with Patched((g, 'append_entry', partial_then(enospc))):
            rc, out = run_import(gB, '--apply')
        check('匯入餌①(寫到一半 ENOSPC):失敗收場,結束碼 1', rc == 1, rc)
        check('匯入餌①:遊戲檔的 sha256 與大小都沒變(舊版這裡檔案會變大)',
              sha_of(bigB) == beforeB and os.path.getsize(bigB) == sizeB)
        check('匯入餌①:畫面說「一個位元組都沒有動」,而這句話是真的',
              '一個位元組都沒有動' in out, out[-200:])
        check('匯入餌①:沒有說成「被動到」,也沒有登記成動過了',
              '被動到' not in out and bigB not in _TOUCHED)
        check('匯入餌①:工作複本被收掉了', leftovers(os.path.dirname(bigB), KEEP) == [],
              leftovers(os.path.dirname(bigB), KEEP))

        # 13-3. 陰性對照:關掉工作複本(直接對正本寫,等於舊做法)—— 上面那些斷言要紅
        gC, bigC = make_game('i_old_path')
        beforeC = sha_of(bigC)
        with Patched((g, 'append_entry', partial_then(enospc)),
                     (g, '_stage_copy', lambda live: live),
                     (g, '_drop', lambda pth: None)):
            rc, out = run_import(gC, '--apply')
        check('陰性對照:關掉工作複本時,遊戲檔真的被寫壞,而畫面照樣說「一個位元組都沒有動」'
              '(= 餌①的兩個斷言在舊做法下都會紅)',
              sha_of(bigC) != beforeC and '一個位元組都沒有動' in out, out[-160:])

        # 13-4. 餌②:最後換名那一步失敗(沒換成)—— 遊戲檔原封不動,不登記
        gD, bigD = make_game('i_replace_fail')
        beforeD = sha_of(bigD)
        with Patched((os, 'replace', replace_fake('fail_before', bigD))):
            rc, out = run_import(gD, '--apply')
        check('匯入餌②(換名失敗,沒換成):失敗收場,遊戲檔原封不動',
              rc == 1 and sha_of(bigD) == beforeD, rc)
        check('匯入餌②:畫面說「一個位元組都沒有動」,沒有登記成動過了',
              '一個位元組都沒有動' in out and bigD not in _TOUCHED)
        check('匯入餌②:工作複本被收掉了', leftovers(os.path.dirname(bigD), KEEP) == [],
              leftovers(os.path.dirname(bigD), KEEP))

        # 13-5. 餌③:換名其實做完了,之後才丟 OSError —— 這時候「沒有動」就是假話
        gE, bigE = make_game('i_replace_then_error')
        beforeE = sha_of(bigE)
        with Patched((os, 'replace', replace_fake('fail_after', bigE))):
            rc, out = run_import(gE, '--apply')
        check('匯入餌③(換名做完才出事):失敗收場,而且遊戲檔確實已經換成新的',
              rc == 1 and sha_of(bigE) != beforeE, rc)
        check('匯入餌③:畫面說「已經被動到」並印出 --restore,絕對不說「一個位元組都沒有動」',
              '被動到' in out and '--restore' in out and '一個位元組都沒有動' not in out,
              out[-200:])
        check('匯入餌③:有登記成動過了', bigE in _TOUCHED)

        # 13-6. 餌④:硬碟空間不夠 —— 在做備份之前就停,結束碼 2
        class _Free(object):
            def __init__(self, n):
                self.free = n

        gF, bigF = make_game('i_nospace')
        beforeF = sha_of(bigF)
        with Patched((shutil, 'disk_usage', lambda pth: _Free(1))):
            rc, out = run_import(gF, '--apply')
        check('匯入餌④(硬碟空間不夠):結束碼 2,畫面說「硬碟空間不夠」', rc == 2 and '硬碟空間不夠' in out,
              '%r %s' % (rc, out[-160:]))
        check('匯入餌④:停在做備份之前 —— 遊戲檔沒動、沒有備份、沒有工作複本',
              sha_of(bigF) == beforeF and leftovers(os.path.dirname(bigF), {'sushared.big'}) == [],
              leftovers(os.path.dirname(bigF), {'sushared.big'}))

        sizeF = os.path.getsize(bigF)

        def room_ok(free, with_backup):
            with Patched((shutil, 'disk_usage', lambda pth: _Free(free))):
                try:
                    _require_space(bigF, with_backup, 100)
                    return True
                except NoSpaceError:
                    return False

        check('空間檢查:只放得下一份複本、而且還要做備份 → 停', room_ok(sizeF + 100, True) is False)
        check('空間檢查:同樣的空間,已經有備份 → 放行', room_ok(sizeF + 100, False) is True)
        check('空間檢查:少一個位元組 → 停', room_ok(sizeF + 99, False) is False)
        check('空間檢查:放得下複本加備份 → 放行', room_ok(2 * sizeF + 100, True) is True)

        def _no_du(pth):
            raise OSError(5, 'cannot stat')
        with Patched((shutil, 'disk_usage', _no_du)):
            try:
                _require_space(bigF, True, 100)
                unmeasured = True
            except NoSpaceError:
                unmeasured = False
        check('空間檢查:量不到剩多少空間時不擋(量不到不等於不夠)', unmeasured)

        # 13-7. 餌⑤⑥⑦:Ctrl-C 落在三個不同的位置,畫面上的話都要是真的
        gG, bigG = make_game('i_ctrlc_write')
        beforeG = sha_of(bigG)
        with Patched((g, 'append_entry', partial_then(KeyboardInterrupt))):
            rc, out = run_import(gG, '--apply')
        check('匯入餌⑤(Ctrl-C 落在寫工作複本中途):結束碼 130,遊戲檔原封不動',
              rc == 130 and sha_of(bigG) == beforeG, rc)
        check('匯入餌⑤:畫面說「一個位元組都沒有動到」,工作複本被收掉了',
              '一個位元組都沒有動到' in out and bigG not in _TOUCHED
              and leftovers(os.path.dirname(bigG), KEEP) == [], out[-160:])

        gH, bigH = make_game('i_ctrlc_before_swap')
        beforeH = sha_of(bigH)
        with Patched((os, 'replace', replace_fake('ctrl_c_before', bigH))):
            rc, out = run_import(gH, '--apply')
        check('匯入餌⑥(Ctrl-C 落在換名之前):結束碼 130,遊戲檔原封不動,登記被收回',
              rc == 130 and sha_of(bigH) == beforeH and bigH not in _TOUCHED, rc)
        check('匯入餌⑥:畫面說「一個位元組都沒有動到」,工作複本被收掉了',
              '一個位元組都沒有動到' in out and leftovers(os.path.dirname(bigH), KEEP) == [],
              out[-160:])

        gI, bigI = make_game('i_ctrlc_after_swap')
        beforeI = sha_of(bigI)
        with Patched((os, 'replace', replace_fake('ctrl_c_after', bigI))):
            rc, out = run_import(gI, '--apply')
        check('匯入餌⑦(Ctrl-C 落在換名之後):結束碼 130,遊戲檔確實已經換成新的',
              rc == 130 and sha_of(bigI) != beforeI, rc)
        check('匯入餌⑦:畫面說「已經動過了」並印出 --restore,不說「一個位元組都沒有動到」',
              '已經動過了' in out and '--restore' in out and '一個位元組都沒有動到' not in out
              and bigI in _TOUCHED, out[-200:])

        # 13-8. 餌⑧:複本寫完沒出錯、但內容是壞的 —— 換名之前的讀回驗證要擋下來
        gJ, bigJ = make_game('i_corrupt_copy')
        beforeJ = sha_of(bigJ)
        with Patched((g, 'append_entry', corrupt_append)):
            rc, out = run_import(gJ, '--apply')
        check('匯入餌⑧(複本內容是壞的):結束碼 1,遊戲檔原封不動', rc == 1 and sha_of(bigJ) == beforeJ,
              '%r %s' % (rc, out[-160:]))
        check('匯入餌⑧:畫面說「沒有換上去」,工作複本被收掉了',
              '沒有換上去' in out and leftovers(os.path.dirname(bigJ), KEEP) == [], out[-160:])

        # 13-9. 餌⑨:遊戲檔本身是符號連結(備份已經在,所以不會先被備份那一道擋掉)
        try:
            gK, bigK = make_game('i_symlink', backup=True)
            realK = os.path.join(outside_dir, 'real_sushared.big')
            os.rename(bigK, realK)
            os.symlink(realK, bigK)
            link_ok = True
        except (OSError, NotImplementedError, AttributeError):
            link_ok = False                 # 這台機器不能建符號連結(例如沒有權限的 Windows)
        if link_ok:
            beforeK = sha_of(realK)
            rc, out = run_import(gK, '--apply')
            check('匯入餌⑨(遊戲檔是符號連結):拒絕,連結另一頭的檔原封不動',
                  rc == 1 and '符號連結' in out and sha_of(realK) == beforeK
                  and os.path.islink(bigK), '%r %s' % (rc, out[-160:]))
            check('匯入餌⑨:沒有留下工作複本', leftovers(os.path.dirname(bigK), KEEP) == [],
                  leftovers(os.path.dirname(bigK), KEEP))
            # 兩道防線各驗一次:_stage_copy 與 _swap_in 都自己擋,不靠對方。
            try:
                _stage_copy(bigK)
                said = '(沒有拒絕)'
            except DataError as err:
                said = str(err)
            check('匯入餌⑨:_stage_copy 自己就拒絕符號連結,而且沒有留下複本',
                  '符號連結' in said and leftovers(os.path.dirname(bigK), KEEP) == [], said[:60])
            dummy = write(os.path.join(os.path.dirname(bigK), '.dummy-work'), b'x')
            try:
                _swap_in(dummy, bigK)
                said = '(沒有拒絕)'
            except DataError as err:
                said = str(err)
            finally:
                os.remove(dummy)
            check('匯入餌⑨:_swap_in 自己也拒絕符號連結,連結另一頭的檔原封不動,沒有登記動過了',
                  '符號連結' in said and sha_of(realK) == beforeK and bigK not in _TOUCHED, said[:60])
        else:
            print('  - 略過 匯入餌⑨:這台機器建不了符號連結')

        # 13-10. 餌⑩:遊戲檔是唯讀的
        gL, bigL = make_game('i_readonly', backup=True)
        beforeL = sha_of(bigL)
        os.chmod(bigL, 0o444)
        try:
            try:
                with open(bigL, 'r+b'):
                    can_write = True        # 用系統管理員身分跑時唯讀擋不住,這一組就沒意義
            except OSError:
                can_write = False
            if can_write:
                print('  - 略過 匯入餌⑩:這個身分不受唯讀限制')
            else:
                rc, out = run_import(gL, '--apply')
                check('匯入餌⑩(遊戲檔唯讀):失敗收場,遊戲檔原封不動', rc == 1 and sha_of(bigL) == beforeL, rc)
                check('匯入餌⑩:畫面說「一個位元組都沒有動」,沒有留下工作複本',
                      '一個位元組都沒有動' in out and bigL not in _TOUCHED
                      and leftovers(os.path.dirname(bigL), KEEP) == [], out[-160:])
        finally:
            os.chmod(bigL, 0o644)

        # 13-11. 餌⑪⑫:複製工作複本這一步本身出事(備份已經在,所以第一次複製就是複本)
        def die_mid_copy(fsrc, fdst, length=0):
            fdst.write(fsrc.read(1024))
            raise enospc()

        def wrong_copy(fsrc, fdst, length=0):
            fdst.write(b'\x00' * len(fsrc.read()))

        gM, bigM = make_game('i_enospc_copy', backup=True)
        beforeM = sha_of(bigM)
        with Patched((shutil, 'copyfileobj', die_mid_copy)):
            rc, out = run_import(gM, '--apply')
        check('匯入餌⑪(複製工作複本時磁碟滿):失敗收場,遊戲檔原封不動',
              rc == 1 and sha_of(bigM) == beforeM, '%r %s' % (rc, out[-160:]))
        check('匯入餌⑪:畫面說「一個位元組都沒有動」,備份說成「先前就有的」,半截複本被收掉了',
              '一個位元組都沒有動' in out and '先前就有的' in out and bigM not in _TOUCHED
              and leftovers(os.path.dirname(bigM), KEEP) == [], out[-200:])

        gO, bigO = make_game('i_wrong_copy', backup=True)
        beforeO = sha_of(bigO)
        with Patched((shutil, 'copyfileobj', wrong_copy)):
            rc, out = run_import(gO, '--apply')
        check('匯入餌⑫(複製出來的工作複本內容不對):擋下來,遊戲檔原封不動',
              rc == 1 and '複製出來的工作複本' in out and sha_of(bigO) == beforeO,
              '%r %s' % (rc, out[-160:]))
        check('匯入餌⑫:沒有登記動過了,工作複本被收掉了',
              bigO not in _TOUCHED and leftovers(os.path.dirname(bigO), KEEP) == [],
              leftovers(os.path.dirname(bigO), KEEP))

        # 13-12. 餌⑬:換上去之後,照目錄表讀回來的時候讀不動(OSError)——
        # 要自動用備份還原並說實話,不是吐一頁 traceback。
        gN, bigN = make_game('i_readback_oserror')
        beforeN = sha_of(bigN)
        state = {'swapped': False}
        real_read_entry = g['read_entry']

        def read_entry_after_swap(pth, off, size):
            if state['swapped']:
                raise OSError(5, 'Input/output error')
            return real_read_entry(pth, off, size)

        def replace_then_flag(src, dst):
            real_replace_all(src, dst)
            if os.fspath(dst) == bigN:
                state['swapped'] = True

        with Patched((os, 'replace', replace_then_flag), (g, 'read_entry', read_entry_after_swap)):
            rc, out = run_import(gN, '--apply')
        check('匯入餌⑬(換上去之後讀不回來):不吐 traceback,結束碼 1,畫面說複驗沒過並已經還原',
              rc == 1 and '複驗沒過' in out and '還原' in out, '%r %s' % (rc, out[-200:]))
        check('匯入餌⑬:遊戲檔已經用備份還原回換之前的樣子', sha_of(bigN) == beforeN)

        # 13-13. 餌⑭~⑱:換名出事之後,「換上去了沒」要問磁碟、認檔案身分,分三種(2026-10-30 加)
        # 舊做法只看「工作複本還在不在」:複本被防毒軟體移走、或碟斷線時換名根本沒發生,
        # 卻宣稱遊戲檔「已經被動到了」。這一組是「換名沒發生 / 問不出來」的各種情形。
        real_lstat, real_stat = os.lstat, os.stat
        gone = {'on': False}               # 打開 = 那顆碟讀不到了(每一個問磁碟的呼叫都丟 EIO)

        def gone_lstat(pth, *a, **k):
            if gone['on']:
                raise OSError(5, 'Input/output error')
            return real_lstat(pth, *a, **k)

        def gone_stat(pth, *a, **k):
            if gone['on']:
                raise OSError(5, 'Input/output error')
            return real_stat(pth, *a, **k)

        gone_patch = ((os, 'lstat', gone_lstat), (os, 'stat', gone_stat))

        def swap_fake(big_path, mode):
            """os.replace 的替身:只對「換到遊戲檔身上」那一次動手腳,其他換名照常。"""
            def fake(src, dst):
                if os.fspath(dst) != big_path:
                    return real_replace_all(src, dst)
                if mode == 'vanish':        # 換名之前,工作複本被別的程式移走了
                    os.remove(src)
                    return real_replace_all(src, dst)       # 換名自己丟 FileNotFoundError
                if mode == 'disk_gone':
                    gone['on'] = True
                    raise OSError(5, 'Input/output error')
                if mode == 'disk_gone_ctrl_c':
                    gone['on'] = True
                    raise KeyboardInterrupt()
                if mode == 'someone_else':  # 遊戲檔先被別的程式換成另一份,我們這次換名才失敗
                    other = write(os.path.join(os.path.dirname(big_path), '.someone-else'),
                                  b'SOMEONE-ELSE' * 100)
                    real_replace_all(other, dst)
                    raise OSError(13, 'Permission denied')
            return fake

        def run_gone(gd, big, mode):
            try:
                with Patched((os, 'replace', swap_fake(big, mode)), *gone_patch):
                    return run_import(gd, '--apply')
            finally:
                gone['on'] = False

        gQ, bigQ = make_game('i_work_vanished')
        beforeQ = sha_of(bigQ)
        with Patched((os, 'replace', swap_fake(bigQ, 'vanish'))):
            rc, out = run_import(gQ, '--apply')
        check('匯入餌⑭(換名前工作複本就被移走了):失敗收場,遊戲檔原封不動',
              rc == 1 and sha_of(bigQ) == beforeQ, '%r %s' % (rc, out[-160:]))
        check('匯入餌⑭:畫面說「一個位元組都沒有動」,不說「被動到」也不說「沒辦法確認」,沒有登記動過了',
              '一個位元組都沒有動' in out and '被動到' not in out and '沒辦法確認' not in out
              and bigQ not in _TOUCHED and leftovers(os.path.dirname(bigQ), KEEP) == [], out[-200:])

        def old_swap_in(work_, live_):
            """2026-10-30 之前的 _swap_in:換名失敗只看「複本還在不在」(陰性對照用)。"""
            work_, live_ = os.fspath(work_), os.fspath(live_)
            already_ = live_ in _TOUCHED
            _mark_touched(live_)
            try:
                os.replace(work_, live_)
            except BaseException:
                if not already_ and os.path.lexists(work_):
                    _unmark_touched(live_)
                raise

        gQ2, bigQ2 = make_game('i_work_vanished_old')
        beforeQ2 = sha_of(bigQ2)
        with Patched((os, 'replace', swap_fake(bigQ2, 'vanish')), (g, '_swap_in', old_swap_in)):
            rc, out = run_import(gQ2, '--apply')
        check('陰性對照:換回舊的 _swap_in,同一個情境(遊戲檔其實沒動)會講出假話「被動到」'
              '(= 餌⑭在舊做法下會紅)',
              sha_of(bigQ2) == beforeQ2 and '被動到' in out and bigQ2 in _TOUCHED, out[-160:])

        gR, bigR = make_game('i_disk_gone')
        rc, out = run_gone(gR, bigR, 'disk_gone')
        check('匯入餌⑮(換名那一刻碟讀不到了):失敗收場,結束碼 1,不吐 traceback',
              rc == 1, '%r %s' % (rc, out[-160:]))
        check('匯入餌⑮:畫面老實說「沒辦法確認有沒有已經換成新的」並指到 --export;'
              '不說「一個位元組都沒有動」也不說「被動到」,備份不說成「還沒做成」',
              '有沒有已經換成新的' in out and '--export' in out and '一個位元組都沒有動' not in out
              and '被動到' not in out and '也還沒做成' not in out, out[-300:])
        check('匯入餌⑮:保守起見留著「可能動過了」的登記,而且記成沒辦法確認',
              bigR in _TOUCHED and _is_unsure(bigR))

        gS, bigS = make_game('i_ctrlc_disk_gone')
        rc, out = run_gone(gS, bigS, 'disk_gone_ctrl_c')
        check('匯入餌⑯(Ctrl-C 落在換名、而且碟讀不到):結束碼 130,畫面說「沒辦法確認」並指到 --export',
              rc == 130 and '沒辦法確認有沒有已經換成新的' in out and '--export' in out,
              '%r %s' % (rc, out[-200:]))
        check('匯入餌⑯:不說「已經動過了」,也不說「一個位元組都沒有動到」',
              '已經動過了' not in out and '一個位元組都沒有動到' not in out, out[-200:])

        gT, bigT = make_game('i_someone_else')
        with Patched((os, 'replace', swap_fake(bigT, 'someone_else'))):
            rc, out = run_import(gT, '--apply')
        check('匯入餌⑰(換名失敗、而且遊戲檔不知被誰換成了另一份):不猜,畫面說沒辦法確認,'
              '不說「一個位元組都沒有動」,登記記成沒辦法確認',
              rc == 1 and '有沒有已經換成新的' in out and '一個位元組都沒有動' not in out
              and _is_unsure(bigT), '%r %s' % (rc, out[-200:]))

        gU, bigU = make_game('i_no_inode')
        beforeU = sha_of(bigU)
        with Patched((g, '_identity_of', lambda pth: None),
                     (os, 'replace', replace_fake('fail_before', bigU))):
            rc, out = run_import(gU, '--apply')
        check('匯入餌⑱(檔案系統認不出身分,換名失敗,複本還在原處):退一步確定沒換成,'
              '說「一個位元組都沒有動」,收回登記',
              rc == 1 and '一個位元組都沒有動' in out and bigU not in _TOUCHED
              and sha_of(bigU) == beforeU, '%r %s' % (rc, out[-160:]))
        gV, bigV = make_game('i_no_inode_vanish')
        with Patched((g, '_identity_of', lambda pth: None),
                     (os, 'replace', swap_fake(bigV, 'vanish'))):
            rc, out = run_import(gV, '--apply')
        check('匯入餌⑱:認不出身分、複本又不見了 → 不猜,說沒辦法確認',
              rc == 1 and '有沒有已經換成新的' in out and '一個位元組都沒有動' not in out
              and _is_unsure(bigV), '%r %s' % (rc, out[-160:]))

        # 登記簿與身分小工具的單元檢查
        up = os.path.join(work, 'unit.big')
        del _TOUCHED[:]
        del _UNSURE[:]
        _mark_unsure(up)
        check('登記簿:_mark_unsure 留在 _TOUCHED(保守),另外記成沒辦法確認',
              up in _TOUCHED and _is_unsure(up))
        _mark_touched(up)
        check('登記簿:之後確定動過了(_mark_touched)就不再算沒辦法確認',
              up in _TOUCHED and not _is_unsure(up))
        _mark_unsure(up)
        _unmark_touched(up)
        check('登記簿:收回登記(_unmark_touched)兩份清單都清乾淨',
              up not in _TOUCHED and up not in _UNSURE)
        del _TOUCHED[:]
        del _UNSURE[:]
        import types
        pf = write(os.path.join(work, 'pres.bin'), b'x')
        check('身分小工具:_presence 在的檔 → True、資料夾還在而檔不在 → False、整個資料夾不在 → None',
              _presence(pf) is True and _presence(pf + '.nope') is False
              and _presence(os.path.join(work, 'no_such_dir', 'x')) is None)
        with Patched((os, 'lstat', lambda pth: types.SimpleNamespace(
                st_dev=1, st_ino=0, st_size=5, st_mtime_ns=9))):
            no_ino = _identity_of(pf)
        with Patched((os, 'lstat', lambda pth: types.SimpleNamespace(
                st_dev=1, st_ino=7, st_size=5, st_mtime_ns=9))):
            with_ino = _identity_of(pf)
        check('身分小工具:st_ino 是 0 的檔案系統認不出身分(None),有 inode 的回四個值;問不到也回 None',
              no_ino is None and with_ino == (1, 7, 5, 9) and _identity_of(pf + '.nope') is None)

        # 13-14. 餌⑲:空間預檢那一步量不到封裝檔大小(外接碟在 decode_screen 之後、預檢之前不見了)
        real_getsize, real_require = os.path.getsize, g['_require_space']
        armed = {'on': False}

        def eio_getsize(pth):
            if armed['on']:
                raise OSError(5, 'Input/output error')
            return real_getsize(pth)

        def require_fake(mode):
            def wrapped(bp, wb, extra):
                if mode == 'enoent':
                    os.remove(bp)                       # 真的讓 getsize 丟 FileNotFoundError
                armed['on'] = mode != 'enoent'
                gone['on'] = (mode == 'disk_gone')
                try:
                    return real_require(bp, wb, extra)
                finally:
                    armed['on'] = False
                    gone['on'] = False
            return wrapped

        def run_space(gd, mode):
            with Patched((g, '_require_space', require_fake(mode)),
                         (os.path, 'getsize', eio_getsize), *gone_patch):
                return run_import(gd, '--apply')

        gW, bigW = make_game('i_getsize_eio')
        beforeW = sha_of(bigW)
        rc, out = run_space(gW, 'eio')
        check('匯入餌⑲-1(預檢量大小丟 EIO,檔案其實還在):不吐 traceback,結束碼 1,'
              '照實說檔還在、遊戲檔與備份都沒有被碰過',
              rc == 1 and '還在,只是讀不出它的大小' in out and '都沒有被碰過' in out
              and 'Input/output error' in out, '%r %s' % (rc, out[-200:]))
        check('匯入餌⑲-1:遊戲檔原封不動,沒有備份、沒有工作複本',
              sha_of(bigW) == beforeW and leftovers(os.path.dirname(bigW), {'sushared.big'}) == [],
              leftovers(os.path.dirname(bigW), {'sushared.big'}))
        gX, bigX = make_game('i_getsize_enoent')
        rc, out = run_space(gX, 'enoent')
        check('匯入餌⑲-2(預檢量大小丟 ENOENT,檔案真的不見了):不吐 traceback,結束碼 1,'
              '照實說檔不在那個資料夾裡、遊戲檔與備份都沒有被碰過',
              rc == 1 and '現在不在那個資料夾裡了' in out and '都沒有被碰過' in out
              and '本工具還沒替它做過備份' in out, '%r %s' % (rc, out[-200:]))
        check('匯入餌⑲-2:沒有生出備份也沒有工作複本', os.listdir(os.path.dirname(bigX)) == [],
              os.listdir(os.path.dirname(bigX)))
        gY, bigY = make_game('i_getsize_disk_gone')
        beforeY = sha_of(bigY)
        rc, out = run_space(gY, 'disk_gone')
        check('匯入餌⑲-3(預檢時整顆碟讀不到):不吐 traceback,結束碼 1,'
              '不亂說檔案不在或備份沒有,照實說「讀不到」',
              rc == 1 and '現在讀不到' in out and '備份在不在現在也看不到' in out
              and '現在不在那個資料夾裡了' not in out and '還沒替它做過備份' not in out,
              '%r %s' % (rc, out[-200:]))
        check('匯入餌⑲-3:遊戲檔原封不動,沒有備份、沒有工作複本',
              sha_of(bigY) == beforeY and leftovers(os.path.dirname(bigY), {'sushared.big'}) == [],
              leftovers(os.path.dirname(bigY), {'sushared.big'}))

        # 13-15. 餌⑳:遊戲檔是符號連結、而且空間不夠,要先講符號連結,不要先講空間
        try:
            gP, bigP = make_game('i_symlink_lowspace')
            realP = os.path.join(outside_dir, 'real_sushared_lowspace.big')
            os.rename(bigP, realP)
            os.symlink(realP, bigP)
            link_ok2 = True
        except (OSError, NotImplementedError, AttributeError):
            link_ok2 = False
        if link_ok2:
            beforeP = sha_of(realP)
            with Patched((shutil, 'disk_usage', lambda pth: _Free(1))):
                rc, out = run_import(gP, '--apply')
            check('匯入餌⑳(遊戲檔是符號連結、而且空間不夠):先講符號連結(結束碼 1),不講「硬碟空間不夠」',
                  rc == 1 and '符號連結' in out and '硬碟空間不夠' not in out,
                  '%r %s' % (rc, out[-200:]))
            check('匯入餌⑳:連結另一頭的檔原封不動,沒有備份、沒有工作複本',
                  sha_of(realP) == beforeP and os.path.islink(bigP)
                  and leftovers(os.path.dirname(bigP), {'sushared.big'}) == [],
                  leftovers(os.path.dirname(bigP), {'sushared.big'}))
        else:
            print('  - 略過 匯入餌⑳:這台機器建不了符號連結')

        # 13-16. 還原餌⑥~⑪:--restore 的換名也要「先登記、出事問磁碟」(2026-10-30.2 加)
        # 舊做法是 os.replace 換完才 _mark_touched:Ctrl-C 剛好落在兩步之間,檔案已經
        # 換成備份的樣子,登記簿卻是空的,收尾會說「一個位元組都沒有動到」(假話)。
        def make_restore_game(tag):
            gd_, bg_ = make_game(tag, backup=True)
            with open(bg_, 'ab') as ff:
                ff.write(b'EDITED-AFTER-BACKUP' * 8)   # 遊戲檔跟備份不一樣,還原才有事可做
            return gd_, bg_

        def run_restore(gd_):
            del _TOUCHED[:]
            del _UNSURE[:]
            old_argv = sys.argv
            sys.argv = ['mvp_swap_screen.py', gd_, '--restore']
            buf = io.StringIO()
            try:
                with contextlib.redirect_stdout(buf):
                    rc_ = main()
            except Exception as exc:
                rc_ = 'traceback(%s)' % type(exc).__name__
            finally:
                sys.argv = old_argv
            return rc_, buf.getvalue()

        def run_restore_gone(gd_, patch):
            try:
                with Patched(patch, *gone_patch):
                    return run_restore(gd_)
            finally:
                gone['on'] = False

        gZ0, bigZ0 = make_restore_game('r_ok')
        rc, out = run_restore(gZ0)
        check('還原登記:正常 --restore 做得完(陰性對照)。結束碼 0、遊戲檔回到備份的樣子、'
              '登記動過了(不是沒辦法確認)、沒有留下暫存檔',
              rc == 0 and _same_bytes(bigZ0, bigZ0 + BACKUP_SUFFIX) and bigZ0 in _TOUCHED
              and not _is_unsure(bigZ0) and leftovers(os.path.dirname(bigZ0), KEEP) == [],
              '%r %s' % (rc, out[-160:]))

        gZ1, bigZ1 = make_restore_game('r_ctrlc_after')
        with Patched((os, 'replace', replace_fake('ctrl_c_after', bigZ1))):
            rc, out = run_restore(gZ1)
        check('還原餌⑥(換名成功、Ctrl-C 落在換名之後、登記之前):結束碼 130,遊戲檔已經是備份的樣子',
              rc == 130 and _same_bytes(bigZ1, bigZ1 + BACKUP_SUFFIX), '%r %s' % (rc, out[-160:]))
        check('還原餌⑥:登記簿有這個檔(而且不是沒辦法確認),畫面說它「已經動過了」,'
              '不說「一個位元組都沒有動到」(舊版漏登,會說這句假話)',
              bigZ1 in _TOUCHED and not _is_unsure(bigZ1) and '已經動過了' in out
              and '一個位元組都沒有動到' not in out, out[-200:])

        def old_do_copy(bak_, dst_):
            """2026-10-30.2 之前的 _do_copy:os.replace 換完才登記(陰性對照用)。"""
            bak_, dst_ = os.fspath(bak_), os.fspath(dst_)
            fd_, tmp_ = _new_temp_beside(dst_, '.restore-')
            try:
                with os.fdopen(fd_, 'wb') as out_:
                    with open(bak_, 'rb') as f_:
                        shutil.copyfileobj(f_, out_, 1 << 20)
                os.replace(tmp_, dst_)
                _mark_touched(dst_)
            except BaseException:
                try:
                    os.remove(tmp_)
                except OSError:
                    pass
                raise

        gZ1b, bigZ1b = make_restore_game('r_ctrlc_after_old')
        with Patched((g, '_do_copy', old_do_copy), (os, 'replace', replace_fake('ctrl_c_after', bigZ1b))):
            rc, out = run_restore(gZ1b)
        check('陰性對照:換回舊的 _do_copy,同一個情境(檔案其實已經換成備份的樣子)會漏登、'
              '說出假話「一個位元組都沒有動到」(= 餌⑥在舊做法下會紅)',
              rc == 130 and _same_bytes(bigZ1b, bigZ1b + BACKUP_SUFFIX) and bigZ1b not in _TOUCHED
              and '一個位元組都沒有動到' in out, '%r %s' % (rc, out[-160:]))

        gZ2, bigZ2 = make_restore_game('r_ctrlc_before')
        beforeZ2 = sha_of(bigZ2)
        with Patched((os, 'replace', replace_fake('ctrl_c_before', bigZ2))):
            rc, out = run_restore(gZ2)
        check('還原餌⑦(Ctrl-C 落在換名之前,換名根本沒發生):結束碼 130,遊戲檔原封不動,'
              '收回登記,畫面說「一個位元組都沒有動到」,沒有留下暫存檔',
              rc == 130 and sha_of(bigZ2) == beforeZ2 and bigZ2 not in _TOUCHED
              and '一個位元組都沒有動到' in out and leftovers(os.path.dirname(bigZ2), KEEP) == [],
              '%r %s' % (rc, out[-160:]))

        gZ3, bigZ3 = make_restore_game('r_work_vanished')
        beforeZ3 = sha_of(bigZ3)
        with Patched((os, 'replace', swap_fake(bigZ3, 'vanish'))):
            rc, out = run_restore(gZ3)
        check('還原餌⑧(換名前暫存檔就被移走了):失敗收場(結束碼 1),遊戲檔原封不動,'
              '登記簿沒有它,不說成沒辦法確認',
              rc == 1 and sha_of(bigZ3) == beforeZ3 and bigZ3 not in _TOUCHED
              and not _is_unsure(bigZ3), '%r %s' % (rc, out[-160:]))

        gZ4, bigZ4 = make_restore_game('r_ctrlc_disk_gone')
        rc, out = run_restore_gone(gZ4, (os, 'replace', swap_fake(bigZ4, 'disk_gone_ctrl_c')))
        check('還原餌⑨(Ctrl-C 落在換名、而且碟讀不到):結束碼 130,登記記成沒辦法確認,'
              '畫面說「沒辦法確認」,不說「一個位元組都沒有動到」',
              rc == 130 and _is_unsure(bigZ4) and '沒辦法確認有沒有已經換成新的' in out
              and '一個位元組都沒有動到' not in out, '%r %s' % (rc, out[-200:]))

        gZ5, bigZ5 = make_restore_game('r_someone_else')
        with Patched((os, 'replace', swap_fake(bigZ5, 'someone_else'))):
            rc, out = run_restore(gZ5)
        check('還原餌⑩(換名失敗、而且遊戲檔不知被誰換成了另一份):不猜,登記記成沒辦法確認',
              rc == 1 and _is_unsure(bigZ5), '%r %s' % (rc, out[-160:]))

        gZ6, bigZ6 = make_restore_game('r_already_marked')
        beforeZ6 = sha_of(bigZ6)
        del _TOUCHED[:]
        del _UNSURE[:]
        _mark_touched(bigZ6)               # 例:--import 換上去之後複驗沒過,自動還原才會走到這裡
        with Patched((os, 'replace', replace_fake('fail_before', bigZ6))):
            try:
                _do_copy(bigZ6 + BACKUP_SUFFIX, bigZ6)
                failed = False
            except OSError:
                failed = True
        check('還原餌⑪(先前就登記過,這次還原的換名失敗):失敗收場,遊戲檔原封不動,'
              '先前的登記不可以被收掉',
              failed and sha_of(bigZ6) == beforeZ6 and bigZ6 in _TOUCHED
              and not _is_unsure(bigZ6))
    finally:
        shutil.rmtree(root, ignore_errors=True)

    print('')
    if fails:
        print('  ❌ %d 項沒過:%s' % (len(fails), '、'.join(fails)))
        return 1
    print('  ✅ 全部 %d 項通過' % passes[0])
    return 0


def main():
    """命令列入口。先擋掉明顯給錯的路徑與代號,再分派到四個動作去。

    回傳值就是 exit code:0 成功、1 是檔案內容不如預期(DataError,
    以及 --restore 有任何一份沒還原成功)、2 是參數給錯了,
    或硬碟空間不夠放工作複本(NoSpaceError,那時候還沒動任何檔案)。
    分開是為了讓看輸出的人分得出「我打錯了」
    跟「這個遊戲資料夾有問題」,這兩件事的下一步完全不同。
    """
    ap = argparse.ArgumentParser(description='MVP Baseball 2005 開機/標題/讀取畫面替換工具')
    # gamedir 改成可省略,只是為了讓 --selftest 不必硬塞一個假路徑進來。
    # 其他四個動作照舊一定要給,沒給就在下面擋掉並回 2,指令用法一個字都沒變。
    ap.add_argument('gamedir', nargs='?', help='遊戲資料夾(裡面要有 data 這個子資料夾)')
    ap.add_argument('--info', action='store_true', help='看有哪些畫面、各是什麼規格')
    ap.add_argument('--export', nargs=2, metavar=('代號', '輸出.png'))
    ap.add_argument('--import', nargs=2, metavar=('代號', '來源.png'), dest='imp')
    ap.add_argument('--apply', action='store_true', help='真的寫入(不加只做預覽)')
    ap.add_argument('--restore', action='store_true',
                    help='還原本工具做的備份(壞掉的那份會跳過,其它照樣還原)')
    ap.add_argument('--selftest', action='store_true',
                    help='自我測試(不需要遊戲檔,只驗那幾道安全守門員)')
    a = ap.parse_args()

    if a.selftest:
        return selftest()
    if not a.gamedir:
        print('  ❌ 要給遊戲資料夾。例:python3 -I %s "<遊戲資料夾>" --info'
              % os.path.basename(__file__))
        return 2
    # 先確認這真的是遊戲資料夾。路徑給錯是最常見的錯,
    # 在這裡擋下來,比後面丟一句「找不到 splash.big」好懂得多。
    if not os.path.isdir(os.path.join(a.gamedir, 'data')):
        print('  ❌ %s 底下沒有 data 資料夾,這不像是遊戲資料夾' % a.gamedir)
        return 2
    # 代號打錯也在這裡擋,而且把可用的三個印出來。
    for key in (a.export[0] if a.export else None, a.imp[0] if a.imp else None):
        if key is not None and key not in SCREENS:
            print('  ❌ 沒有「%s」這個畫面。可用的是:%s' % (key, '、'.join(SCREENS)))
            return 2
    try:
        if a.restore:
            return cmd_restore(a.gamedir)
        if a.export:
            return cmd_export(a.gamedir, a.export[0], a.export[1])
        if a.imp:
            return cmd_import(a.gamedir, a.imp[0], a.imp[1], a.apply)
        return cmd_info(a.gamedir)
    except NoSpaceError as err:
        print('  ❌ %s' % err)
        return 2
    except DataError as err:
        print('  ❌ %s' % err)
        return 1
    except KeyboardInterrupt:
        # ⚠️ 按 Ctrl-C 的人只想知道一件事:「我的遊戲檔現在是好的還是壞的?」
        #    所以這裡分兩句話講,依據是 _TOUCHED —— 真的動到正本才會登記。
        #    兩種都 exit 130(被訊號中止的慣例),**不可以 exit 0**:
        #    照著教學跑批次的人只看 exit code,0 會被當成「做完了」。
        print('')
        if _TOUCHED:
            # 換名之後分不出新舊的檔,只能說「沒辦法確認」(見 _swap_in),不說成「動過了」。
            sure = [pth for pth in _TOUCHED if pth not in _UNSURE]
            if sure:
                print('  ⚠ 你按了 Ctrl-C,而這些檔已經動過了:')
                for pth in sure:
                    print('      %s' % pth)
                print('    要回到原狀就跑:python3 -I %s "%s" --restore'
                      % (os.path.basename(__file__), a.gamedir))
            unsure = [pth for pth in _TOUCHED if pth in _UNSURE]
            if unsure:
                print('  ⚠ 你按了 Ctrl-C,而這些檔沒辦法確認有沒有已經換成新的(碟讀不到、或檔案被別的程式動過):')
                for pth in unsure:
                    print('      %s' % pth)
                print('    碟接好之後,用 --export 把那個畫面匯出來看一眼:是新圖就不必做事,'
                      '打不開或是壞的才跑 --restore。')
        else:
            print('  ⚠ 你按了 Ctrl-C。遊戲檔一個位元組都沒有動到,直接再跑一次就好。')
        return 130


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

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

接下來