教學 › 把你改好的東西打包成一包

把你改好的東西打包成一包

你花了幾個晚上做好一份名單,朋友問「怎麼給我」。 本站每一支會寫檔的工具,都會在第一次寫入之前留一份備份 —— 所以「哪些檔旁邊有備份」就等於「你改過哪些檔」。 這一課照著那個紀錄打包,你不用自己記得改過什麼。

難度

★★☆ 要打指令

時間

約 10 分鐘

給誰

做完一整包想分享的人

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

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

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

⚡ 只想趕快做完?照這五步(要先裝好 Python,沒裝的看第 0 課操作五)

  1. 下載 mvp_pack.py。下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列,把字刪掉改打 cmd 按 Enter,後面的指令都在開出來的這個視窗打(Mac 開終端機先打 cd ~/MVP_Tools)。接著看你改過什麼:

    Windows:

    python -I mvp_pack.py "〔你的遊戲資料夾〕" --scan

    Mac / Linux:

    python3 -I mvp_pack.py "〔你的遊戲資料夾〕" --scan
  2. 打包(打出來的 台灣之光.mvpmod 會存在命令視窗所在的資料夾):

    Windows:

    python -I mvp_pack.py "〔你的遊戲資料夾〕" --build 台灣之光.mvpmod --name "台灣之光" --author "你的名字"

    Mac / Linux:

    python3 -I mvp_pack.py "〔你的遊戲資料夾〕" --build 台灣之光.mvpmod --name "台灣之光" --author "你的名字"
  3. 對方拿到之後,把那個 .mvpmod 放在他跑指令的那個資料夾,先預覽(不會寫任何東西):

    Windows:

    python -I mvp_pack.py "〔他的遊戲資料夾〕" --install 台灣之光.mvpmod

    Mac / Linux:

    python3 -I mvp_pack.py "〔他的遊戲資料夾〕" --install 台灣之光.mvpmod
  4. 確定了才加 --apply。包裡有 .exe/.dll 這類會執行的程式碼, 預設整包不裝(結束碼 1),你信得過做這一包的人才再加 --allow-code (為什麼)。要退回去就 --restore —— 驗不出完整性的備份預設不蓋,那個遊戲檔一個位元組都不動、結束碼 1; 你自己確定它是完整的,再加 --allow-unverified 跑一次。

    Windows:

    python -I mvp_pack.py "〔他的遊戲資料夾〕" --restore

    Mac / Linux:

    python3 -I mvp_pack.py "〔他的遊戲資料夾〕" --restore
  5. 不放心可以先空跑一次自我測試,它不吃遊戲資料夾:

    Windows:

    python -I mvp_pack.py --selftest

    Mac / Linux:

    python3 -I mvp_pack.py --selftest

⚠️ 打出來的那一包裡面是被你改過的 EA 遊戲檔案。 要不要給別人是你自己的判斷 —— 本站不提供散布管道,也不建議公開散布。

想知道為什麼,往下讀;只想複習,跳到📌 重點整理。

怎麼知道你改了什麼

本站二十六支會寫遊戲檔的課程腳本,在第一次寫入之前都會先留一份備份。 大多數各用自己的備份副檔名(.playerbak、.facebak、 .feltoolbak…),這樣同時跑兩支才不會互相覆蓋;只有兩支共用通用的 .bak(換賽程年份、關掉跑壘者速度數值), 但它們動的是不同的檔,所以還是不會蓋到彼此。 「換打擊姿勢與投球姿勢」以前也用 .bak,2026-10-29 起改留專屬的 .stancebak。

數字是 2026-10-30 在本站 site/tutorials/ 底下這一份原始碼上數的: 這二十六支一共產生 26 種備份副檔名,其中一種是 mvp_pack.py 自己安裝別人的模組時留下的 .packbak。 剩下的 25 種(含 2026-09-04 上線的 關掉開場動畫留下的 .introbak, 與換姿勢那一課的 .stancebak), mvp_pack.py 的 BAK_SUFFIXES 清單一種不漏全部認得,所以 --scan 掃得到每一課留下的痕跡。完整對照表在備份與還原 SOP。

(2026-09-05 訂正:這裡原本寫的是 「2026-09-03 數的:二十五支、24 種、剩下 23 種」。 那組數字在 2026-09-04「關掉開場動畫」上線之後就過期了 —— 它留下的 .introbak 當時不在 BAK_SUFFIXES 裡面 —— 那段期間做過那一課再照這一頁打包,改過的開場影片 不會進包,而且螢幕上不會有任何提示。 2026-09-05 把它補進清單並重數,現在掃得到了。)

那些備份就是紀錄。只要遊戲資料夾裡有 attrib.dat 又有 attrib.dat.playerbak, 就代表你用本站工具改過名冊。--scan 做的就是這件事。

這個方法的好處

⚠️ 這個方法看不到什麼

只看得到本站工具留下的備份。你用 TiT、MVPEdit、 或是自己手動覆蓋的檔案,它一個都看不到 —— 那些要用 --add 自己指定:

Windows:

python -I mvp_pack.py "〔遊戲資料夾〕" --build x.mvpmod --name "x" --add data/frontend/ingame.big

Mac / Linux:

python3 -I mvp_pack.py "〔遊戲資料夾〕" --build x.mvpmod --name "x" --add data/frontend/ingame.big

👉 你要做的事

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

1

先看你改過什麼。

Windows:

python -I mvp_pack.py "〔你的遊戲資料夾〕" --scan

Mac / Linux:

python3 -I mvp_pack.py "〔你的遊戲資料夾〕" --scan

會逐行印出檔案、大小、是哪一支工具改的,最後給你總數。

印「找不到任何本站工具留下的備份」→ 你還沒用本站工具改過東西,或者已經全部還原了。

2

打包。--name 是必填的,別人裝之前會先看到它。

Windows:

python -I mvp_pack.py "〔你的遊戲資料夾〕" --build 台灣之光.mvpmod --name "台灣之光 2026" --author "你的名字" --note "旅外那批,接收小聯盟位置"

Mac / Linux:

python3 -I mvp_pack.py "〔你的遊戲資料夾〕" --build 台灣之光.mvpmod \
    --name "台灣之光 2026" --author "你的名字" --note "旅外那批,接收小聯盟位置"

打完它會自己讀回來驗一次: 把包裡每個檔的 SHA-256 跟原檔比對,對不上就當場停下來報錯。

打出來的 台灣之光.mvpmod 會存在命令視窗所在的資料夾(照上面從 MVP_Tools 開的話,就在 MVP_Tools 裡)。對方裝的時候,也要把它放在他跑指令的那個資料夾。

✅ 驗不過的時候,你指定的位置不會生出任何東西。 腳本是先寫一個名字猜不到的暫存檔、驗過雜湊才把它換到你指定的位置, 驗不過就把暫存檔丟掉。 (2026-09-05 本站在這一課的 mvp_pack.py 上實跑: 故意讓 manifest 記的 SHA-256 對不上,打包停在 「這一包不能用……那個位置維持原狀」,你指定的位置沒有生出檔案, 旁邊也沒有留下暫存檔。) 就算真的傳出去了也裝不起來:對方跑 --install --apply 時, 雜湊會在寫第一個檔之前把它擋下來。

(2026-09-05 訂正:這一段原本寫 「驗不過的時候,那一包還在……看到那句話請自己把它刪掉」—— 當時腳本是先改名、再驗雜湊,驗不過會在那個位置留下一個完整、打得開、 卻跟 manifest 對不上的包。2026-09-05 把那一驗移到改名之前, 那句提醒不再成立。)

3

對方先預覽再裝。

Windows:

python -I mvp_pack.py "〔他的遊戲資料夾〕" --preview 台灣之光.mvpmod

Mac / Linux:

python3 -I mvp_pack.py "〔他的遊戲資料夾〕" --preview 台灣之光.mvpmod

會印包名、作者、版本、說明,然後逐行列出裡面每個檔要放到哪裡, 並且逐一確認那些路徑都在他的遊戲資料夾裡面。 大小印的是壓縮檔裡實際的大小,不是包自己寫的;兩邊對不上會標 🔴。 包裡有 .exe、.dll、.bat 這類會執行的程式碼, 會另外列成一塊「🔴 會執行的程式碼」(名稱、大小、SHA-256),見下面那一節。

確定了才裝:

Windows:

python -I mvp_pack.py "〔他的遊戲資料夾〕" --install 台灣之光.mvpmod --apply

Mac / Linux:

python3 -I mvp_pack.py "〔他的遊戲資料夾〕" --install 台灣之光.mvpmod --apply

裝之前每個檔會先備份成 .packbak, 旁邊還會多一個同名的 .packbak.json —— 那是備份的收據(裡面是那份備份的大小與 SHA-256), --restore 靠它判斷備份完不完整,不要刪它。 退回去:--restore。

Windows:

python -I mvp_pack.py "〔他的遊戲資料夾〕" --restore

Mac / Linux:

python3 -I mvp_pack.py "〔他的遊戲資料夾〕" --restore

⚠️ 沒有收據、格式也驗不出證據的備份,--restore 預設不蓋。 那個遊戲檔一個位元組都不動,整支的結束碼是 1,螢幕上會告訴你怎麼放行 —— 你自己確定那一份是完整的,才加 --allow-unverified 再跑一次 (2026-09-05 本站拿一個沒有收據、格式又驗不出來的 .fsh 備份實跑:預設那一次印「驗不出來的預設不蓋……維持原狀」、 結束碼 1、遊戲檔的 SHA-256 前後相同;加了 --allow-unverified 那一次才蓋回去、結束碼 0)。

⚠️ 中途按 Ctrl+C,結束碼是 130, 而且它會告訴你有沒有檔案已經被換掉、換了哪幾個—— 已經動過就叫你跑一次 --restore,一個都還沒動就直說一個都沒動。 (2026-09-05 訂正:在這之前不管有沒有動到檔,一律印「中止了」然後結束碼 0; 批次檔看到 0 會把一套半新半舊的遊戲當成裝好了。本站實跑過「還沒動到檔」 那一半,「已經換掉哪幾個」那一半由 --selftest 的餌測。)

⚠️ 裝別人的包,是在信任那個人

🔴 換掉 .exe / .dll,等於讓那個人在你的電腦上跑程式

一個 .mvpmod 裡的 mvp2005.exe、dinput8.dll 之類的代理 DLL、 改過的 delete.bat,路徑完全合法、SHA-256 也對得上(雜湊是做包的人自己寫的)。 裝進去之後,下一次開遊戲,他的程式碼就以你的身分在跑。 .packbak 可以把檔案還原,但程式只要跑過一次,就已經跑過了。

2026-10-30 起,這支工具預設不裝這種包。 下面兩個條件任何一個成立就算「會執行的程式碼」:

命中的會另外列成「🔴 會執行的程式碼」一塊(名稱、大小、原因、SHA-256)。 沒加 --allow-code 的話,整包不裝(不是只略過那幾個), 結束碼 1,遊戲檔一個位元組都不動,也不會留下備份。 你認識做這一包的人、也核對過 SHA-256,才加:

Windows:

python -I mvp_pack.py "〔他的遊戲資料夾〕" --install 台灣之光.mvpmod --apply --allow-code

Mac / Linux:

python3 -I mvp_pack.py "〔他的遊戲資料夾〕" --install 台灣之光.mvpmod --apply --allow-code

之前能直接裝的包,現在要多加這個旗標,例如附了 mvp2005.exe 的包(改過執行檔的那幾課打出來的)。這是刻意的: 舊版預覽上,mvp2005.exe 跟資料檔長得一模一樣,一行字而已。

⚠️ 清單只列這 34 種副檔名。.msix、.appx、.psm1、 .chm、.msc、.iso 這類也可能跑程式,但不在清單裡(沒加進來,不是確定安全)。 .jar 在清單裡:本站測試機的遊戲資料夾裡就有一個 support/EA Help/webhelp.jar(EA 原廠的說明檔)。這一個有例外:路徑最後一段是 webhelp.jar,而且整個檔的 SHA-256 跟本站在五份安裝上量到、逐位元組相同的 EA 原廠那一份一模一樣,才不算程式碼,畫面照列一行「已知的 EA 原廠檔,略過」(名稱和 SHA-256)。 只憑檔名不行(誰都能把別的 jar 取名 webhelp.jar):同名但內容不同、內容一樣但改了名字, 都還是程式碼,照樣得加 --allow-code。 這也不是病毒掃描:看的只有副檔名和開頭兩個位元組。

(同一天稍後複查指出:清單原本只列十一種,.hta、.wsf 這類文字型的可執行檔沒有 MZ 開頭,兩道判斷都攔不住,沒加 --allow-code 也裝得進去。現在補到 34 種;自我測試對新加的 23 種各試了正反兩面(大小寫、結尾空白與點、另一條資料流、 雙副檔名各至少用一種新的試過),另外用沒有 MZ 開頭的 .hta 真的跑一次 --install --apply:沒加 --allow-code 整包不裝、遊戲檔不動,加了才裝。 一樣只在 Mac 上跑過,還沒在 Windows 上實跑。)

(2026-10-30 本站在 Mac 的拋棄式資料夾實跑過:假遊戲放 data/、mvp2005.exe、delete.bat, 包裡放 attrib.dat、mvp2005.exe、delete.bat、x.dll。 修之前預覽四個檔一個標記都沒有、結束碼 0;修之後 --preview 列出 3 個 🔴、結束碼 1,--install --apply 結束碼 1,兩個遊戲檔的 SHA-256 前後相同。 還沒在 Windows 上實跑。)

一個 .mvpmod 就是一個 ZIP,裡面寫著「這個檔要放到哪裡」。 惡意的包可以要求把檔案寫到遊戲資料夾以外的地方。 這支腳本擋這件事,而且本站實際打過自己:

惡意的包想做的事結果擋它的理由
../OWNED.txt✅ 擋下路徑裡有 ..
/tmp/OWNED.txt✅ 擋下絕對路徑
data/../../OWNED.txt✅ 擋下組完之後跑出遊戲資料夾
C:/OWNED.txt✅ 擋下磁碟機代號。冒號現在不管在第幾個字元都拒絕,所以 data/x.dat:payload(NTFS 的另一條資料流)也一起擋掉
data/CON✅ 擋下 Windows 保留裝置名(CON/PRN/ AUX/COM1…)
包裡的檔跟 manifest 的 SHA-256 對不上✅ 擋下 包被人動過手腳。前提是 manifest 上有寫 sha256;沒寫的項目不比對就裝,見下面那一段。
manifest.json 壞掉、不是 UTF-8、 最外層不是一組設定,或整包根本不是壓縮檔✅ 擋下 講一句人話就停,不會噴一整串 Python 錯誤訊息。 本站拿這四種壞包各實跑一次:四次都只印一行說明、結束碼 1、 Traceback 0 行,測試用的遊戲檔內容沒有變。
包裡夾 mvp2005.exe、dinput8.dll、delete.bat ✅ 預設擋下 另外列成「🔴 會執行的程式碼」,整包不裝;要加 --allow-code。 2026-10-30 才有這一道,見上面那張紅卡。
路徑或說明欄裡夾 ESC 之類的控制字元, 想把游標往上移、清掉整行,再偽造一行「✅ 都沒問題」 ✅ 擋下 路徑含任何控制字元一律拒絕;包名、作者、版本、說明、路徑 印出來之前全部經過同一個函式,控制字元換成 \x1b 這種看得見的寫法。 2026-10-30 才有這一道。
壓縮炸彈:包只有幾百 KB,解開是好幾 GB ✅ 擋下 讀任何一個項目的內容之前,先用壓縮檔目錄上寫的大小檢查: 單項超過 1 GB、整包超過 4 GB、壓縮比超過 200 倍、manifest.json 超過 1 MB 都不讀。 本站拿一個 300 MB 的零、壓縮後 298.6 KB 的包實測(macOS,/usr/bin/time -l): 修之前 --install --apply 最大常駐記憶體 341 MB,而且真的裝進去了; 修之後 --preview 與 --install --apply 都在 26 到 28 MB 之間(量了幾次), --install 被拒絕、遊戲資料夾裡沒有多出檔案。
manifest.json 寫某個檔 10 B,包裡實際是 300 MB ✅ 標 🔴 預覽印的是壓縮檔裡實際的大小,跟 manifest 寫的不一樣就標 「大小不一致」、整包不裝。size 不是整數也一樣,印一句人話,不噴 Traceback。

寫入分三個階段(讀 → 試寫 → 真的寫):先把整包讀進記憶體,全部讀完才動第一個檔, 所以不會裝到一半才發現包是壞的。中間那一關是 2026-09-05 加的: 每一個要寫的位置先在它旁邊建一個名字猜不到的暫存檔再刪掉, 確認真的寫得進去。它擋的是「遊戲正開著、檔案被鎖住」與 「遊戲裝在需要系統管理員權限的地方」這兩種最常見的失敗 —— 本站把目的地資料夾改成唯讀實跑過一次,腳本停在那一關並印 「一個檔都還沒有被改到」,結束碼 1,資料夾裡確實一個檔都沒有多出來。 ⚠️ 這一關過了不代表後面一定寫得成(權限可以在中間被改掉、磁碟可以寫滿)。 真的寫到一半失敗,它會自動把這一輪已經寫進去的檔退回去,結束碼 1; 不過 2026-09-06 起備份與暫存檔全部備妥了才一次換名 —— 真正在寫位元組的是備妥那一段,所以磁碟寫滿、權限中途被改掉這一類失敗 會停在「還沒開始換檔」,結果是全部沒裝, 不是裝了一半再退回去:螢幕上第一句是「還沒開始換檔就失敗了」, 而且明講「遊戲檔一個位元組都沒有動」(本站拿一份真的 attrib.dat 實跑,讓寫暫存檔那一步丟 [Errno 28] No space left on device,印出來的就是這兩句, 遊戲檔前後的 SHA-256 相同)。 中途按 Ctrl+C 則是結束碼 130, 並且列出已經被換掉的是哪幾個檔。本站實跑過一次「還沒動到檔」那一半: 在打包途中送出中斷訊號,結束碼 130、印「一個檔都沒有被動到」, 指定的位置也沒有留下半截的包;「已經換掉哪幾個」那一半由腳本自己的 --selftest 下餌測(換檔的那一瞬間被中斷,不可以漏報)。

但雜湊比對是有條件的。只有 manifest.json 上帶著 sha256 欄位的項目才會比對;沒有這一欄的項目一個雜湊都不驗, 照樣寫進遊戲資料夾。本課 --build 打出來的包每個檔都帶 sha256,所以整包驗得到;別的工具打的包就不一定, 下面「別的工具打的包」那一段講的就是這件事。

⚠️ 這幾道防線擋不住的事

別的工具打的包

.mvpmod 的格式很單純:一個 ZIP,裡面放 manifest.json 與 files/。 所以不是只有本課的 --build 做得出這一支讀得懂的包, 別的工具照這個格式打出來的,一樣可以拿來 --preview、--install。

⚠️ 讀得懂不代表驗得到。 雜湊要由打包的那一方寫進 manifest.json,這一支才有東西可以比。 班主任自己另有一支沒有公開的打包工具就沒寫:它放進檔案清單的每一項只有 path/category/size 三個欄位, 整支程式一次都沒有算 sha256(2026-09-23 看過)。 那種包拿來 --install, 一個雜湊都不會被比對,只會過路徑檢查。

本站在暫存資料夾上實跑過一次確認:故意做一個沒有 sha256 欄位、內容跟宣稱不符的包,--install --apply 照樣印「✅ 裝好了 1 個檔」,測試用的遊戲檔真的被換掉了; 同一個包補上一個對不上的 sha256 再跑一次, 就停在「跟 manifest 寫的 SHA-256 對不上,不敢裝」,檔案沒被動。 所以雜湊擋得住的是「包在路上被改過」,擋不住「打包的人本來就沒寫雜湊」。

本課的路徑安全判斷,當初是照班主任那一支寫的, 但補上一件當時它的說明寫著有、程式碼裡沒有的:Windows 保留裝置名。 2026-08-29 本站實測那一支,CON/PRN/AUX/ COM1/LPT1 一個都沒擋。

✅ 2026-08-30 已修:那一支照本課這一支的做法,補上了同一份保留字清單 (2026-09-23 重測,上面那五個名字都擋下來了)。 所以這一課的教材反過來修好了它抄的那一支。 上面那段留著,是因為它記錄的是當時量到的事實,不是現在的狀態。

📌 重點整理

這支腳本在做什麼

五個動作走的是同一條線:先在遊戲資料夾裡找出「旁邊有本站備份」的檔 → 算每個檔的 SHA-256 → 連同一份 manifest.json 壓成 ZIP → 對方拿到之後逐檔檢查路徑安不安全 → 整包先讀完才開始寫 → 每個被蓋掉的檔留一份 .packbak。 沒有加 --apply 的時候,--install 就等於 --preview,一個位元組都不會寫。

它判斷「你改過什麼」的依據只有一個:某個檔與它的備份同時在同一個資料夾裡。 不跟原版比對,也不要你自己記得。代價是它只看得到本站工具留下的備份, 別的工具改的、手動覆蓋的,要用 --add 自己指定。

哪一段做什麼為什麼要有它
find_changed() 走遍整個遊戲資料夾,找出「某個檔」與「它的備份」 同時存在的組合,並標記兩者是不是逐位元組相同。 一個檔旁邊有好幾份備份時,先把它所有的備份聚在一起,要跟每一份都相同才算「相同」。 這是整支的地基。 順便算出的那個「相同」欄位,會讓你改了又改回去的檔在打包時自動略過, 不必讓別人多蓋一次一模一樣的檔。 只比檔名排最前面的那一份,結果會取決於檔名順序、而不是備份可不可信, 所以改成跟每一份都比。
cmd_scan() 把上面找到的清單印出來:檔名、大小、是哪一支工具改的。 完全不寫檔。 打包之前唯一一次「先看一眼」的機會。 清單裡有沒有你不預期的東西,決定這一包該不該送出去。
sha256() 1 MB 一塊串流讀出整個檔案的雜湊。 不整包讀進記憶體:模組裡可能有 models.big 這種大檔(本站測試機那份量到 561,891,312 bytes ≈ 536 MB;四份剛安裝好的原版都是 172,992,803 bytes ≈ 165 MB,這個數字看你的遊戲被疊過什麼)。
cmd_build() 把改過的檔收成 ZIP, manifest.json 記下每個檔的相對路徑、大小與 SHA-256。 打完立刻重新開檔讀回來驗一次雜湊, 對不上就停下來報錯。整包寫的是一個名字猜不到的暫存檔 (由 tempfile.mkstemp 給名字,不是「目的檔 + .part」 那種猜得到的),驗過了才換到你指定的位置, 所以驗不過就什麼都不會留下(本站實跑:故意讓雜湊對不上, 指定的位置沒有生出檔案,旁邊也沒有留下暫存檔)。 另外,--build 的輸出位置本身也擋一道:那個位置已經有檔案時, 只有「副檔名是 .mvpmod 而且不在遊戲資料夾裡面」才准蓋 (重打同一包的正常情形)。本站兩個餌各跑一次 —— 指到遊戲資料夾裡的 attrib.dat、指到外面一個 .xlsx,兩次都停下來不寫,那兩個檔的大小沒有變。 (2026-09-05 訂正:這一列原本寫「驗雜湊排在改名之後, 那一關沒過會留下一個完整、打得開、卻跟 manifest 對不上的包」—— 那一驗已經移到改名之前。)
is_safe_relpath() 只看字面就拒絕的那幾類寫法:絕對路徑、..、 空路徑段、控制字元(NUL、換行、ESC… 2026-10-30 擴大)、任何位置的冒號、~、 Windows 保留裝置名、結尾的空白與點。 CON 這種裝置名不管副檔名都算數, 而 evil. 建出來會被 Windows 默默改成 evil。 兩種都會造成「檢查時是一個名字、寫出去是另一個名字」。 冒號改成不管在第幾個字元都拒絕是 2026-09-05 改的: 它擋的不只是 C:/ 這種磁碟機代號,還有 data/x.dat:payload —— 那在 NTFS 上是 另一條資料流,會附在 x.dat 身上, 而 safe_target() 解完符號連結看到的路徑仍然在遊戲資料夾裡面, 擋不掉它。遊戲資料夾裡本來就不會有帶冒號的檔名(Windows 不准), 所以不會誤殺。
safe_target() 把相對路徑組成絕對路徑,兩邊都解開符號連結, 再確認它真的落在遊戲資料夾底下。 字面檢查擋不住符號連結: 資料夾裡放一個指向別處的捷徑,路徑看起來還在裡面,寫入卻落到外面。 用 commonpath 而不是字串開頭比對,是因為 startswith('/game') 會把 /game2 也算成在裡面。
read_manifest() / cmd_preview() 讀出包的描述與檔案清單,逐檔顯示要放到哪裡, 並確認每個位置都合法、包裡也真的有那個檔。 2026-10-30 起還多三件事:先用壓縮檔目錄上的大小檢查上限(讀任何項目之前)、 顯示壓縮檔裡實際的大小並跟 manifest 比、 把會執行的程式碼另外列成一塊(SHA-256 是現場串流算的)。整個預覽與安裝只開一次壓縮檔,manifest、目錄、大小上限、認 MZ、雜湊、讀內容都用同一個開著的檔案,不依路徑重新開。 驗的是位置與像不像程式,不是內容。 本站沒有掃描包內容的能力, 一個位置完全正確的 attrib.dat 可以把你的名單改成任何樣子。 大小上限擋壓縮炸彈:zipfile 讀一個項目最多讀到目錄宣告的大小, 所以「宣告的數字不超過上限」就等於「讀進記憶體的不會超過上限」 (本站拿一份把目錄大小改小的包實測過,讀到宣告的大小就截斷、CRC 對不上而報錯)。 超過上限時預覽不讀任何項目的內容(只讀 manifest.json), 本站用一個偵測器記下被打開的項目驗過。只開一次是因為舊寫法在讀內容那一段會依路徑重新開檔:兩次之間包被換成炸彈,第二次開到的沒有人再查過上限就整個解壓;現在換了路徑也不影響這一輪,--allow-code 放行的也是預覽畫面上列過 SHA-256 的那一份。
code_ext_of() / code_reason() 判斷一個檔算不算「會執行的程式碼」:最後一個副檔名在 34 種之內, 或內容開頭是 MZ。判斷前先抹平大小寫、全形、看不見的字元、 結尾的空白與點、冒號後面的另一條資料流。 每一種抹平都對應一種「換個寫法就繞過去」: X.EXE、x.exe.(Windows 會吃掉結尾的點)、 x.dll:abc、readme.txt.exe。 MZ 那一條可能誤中資料檔,所以命中的列出來而不是安靜地拒絕。 cmd_install() 在讀完內容之後自己再判斷一次,不靠預覽有沒有跑過。
clean_text() 所有從 manifest 與檔名讀出來、要印到畫面上的字串, 先把控制類字元(含 ESC、換行、把顯示方向反過來的字元)換成 \x1b 這種看得見的寫法, 太長的截斷。 ESC 序列可以把游標往上移、清掉整行,再印一行假的「✅ 每一個檔都沒問題」, 把真正的檔案清單蓋掉。連最外層的錯誤訊息出口都再洗一次。
cmd_install() 三個階段(讀 → 試寫 → 真的寫):先把每個檔讀進記憶體, 全部讀完之後先在每個要寫的位置建一個名字猜不到的暫存檔再刪掉, 都過了才真的動磁碟。讀的時候順便比對 manifest.json 上的 SHA-256,但只比對得到有寫 sha256 欄位的項目; 動第一個檔之前還會先驗那個檔旁邊已經有的 .packbak 完不完整(它是你唯一的退路,壞的話不敢再往上蓋)。 每個備份旁邊會多寫一張 .packbak.json 收據。 先讀完才寫,就不會裝到一半發現包是壞的, 留下一套半新半舊的遊戲。中間那一關擋的是「遊戲正開著把檔案鎖住」與 「裝在需要系統管理員權限的地方」——本站把目的地資料夾改成唯讀實跑過, 腳本停在那一關,印「一個檔都還沒有被改到」,結束碼 1。 真的寫到一半才失敗時它會自動把這一輪已經寫進去的檔退回去 並告訴你退了幾個,不會自動修好一切: 斷電打斷的話還是要自己跑 --restore。 中途按 Ctrl+C 的結束碼是 130, 而且會分三種講法:已經有檔被換掉就列出是哪幾個並叫你跑 --restore;中斷的那一刻正好正在替換某個檔, 就把那幾個單獨列出來,說明它們換成了沒有腳本答不出來、 當成已經換掉處理比較安全,一樣叫你 --restore; 兩種都沒有才直說一個都沒動 (2026-09-05 之前不管哪一種都印「中止了」然後結束碼 0, 批次檔看到 0 會當成裝好了)。 至於雜湊:沒寫就沒得比, 別的工具打的包可能就是這種(見上面「別的工具打的包」),那種包等於只過路徑檢查。
寫入那一段(cmd_install() 的後半) 每個被蓋掉的檔先備份成 .packbak(只在第一次), 旁邊再寫一張 .packbak.json 收據; 寫檔走名字猜不到的暫存檔加 fsync,讀回來比對過才換名。 只在第一次備份,保留的才是你原始的檔, 裝了第二個模組之後仍然退得回去。收據讓 --restore 驗得出那份備份完不完整。 原子寫入讓遊戲檔在任何時刻要嘛是舊的、要嘛是新的,不會是半截的 —— --restore 2026-09-05 起走的也是同一套,見下面 cmd_restore() 那一列。
cmd_restore() 走遍資料夾把所有 .packbak 蓋回去, 備份留著不刪。蓋回去之前每一份備份都要先過完整性檢查。 只認自己留下的 .packbak, 不會去碰別課留下的備份。 完整性檢查分三層:有 .packbak.json 收據的 比對大小與 SHA-256;沒有收據的看格式自己打不打嘴巴 (BIGF 檔頭寫的整包大小、MZ 開頭執行檔的區段表、 純文字檔的結尾換行);兩層都驗不出來的預設不蓋, 要自己加 --allow-unverified 才會放行。 任何一份沒過,那個遊戲檔一個位元組都不動,整支結束碼 1 (本站實跑:裝完之後把 .packbak 截成一半, --restore 印「收據說它應該有 44 bytes,實際只有 22 —— 是半截的」, 遊戲檔的 SHA-256 前後相同)。 這一段 2026-09-05 起也是原子寫入:先複製到一個名字猜不到的 暫存檔、讀回來比對,通過了才一次改名換上去。 本站在這台 Mac(APFS)上重新量了一次:100 MB 的遊戲檔配一份 150 MB 的 .packbak,每 5 毫秒量一次遊戲檔的大小, 54 個取樣點只量到 104,857,600 與 157,286,400 兩個值,中間沒有第三個。
⚠️ 2026-09-05 之前不是這樣,下面這段是當時量到的樣子, 留著說明為什麼要改:那時它用 copy2 直接蓋回遊戲檔 —— 拿一個 300 MB(314,572,800 bytes)的測試檔當遊戲檔, 配一份同樣大的 .packbak,同樣每 5 毫秒量一次,量到 314,572,800 → 0 → 1,048,576 一路長回 314,572,800,那一趟裡出現 22 個不同的大小 (這個數字每次跑都不一樣,同一台機器重跑六趟, 16 到 28 個不等;重點是它不只一個),也就是 遊戲檔本人先被清成 0,再一段一段長回去;同一份檔改走 --install --apply(300 MB 換成 200 MB),同樣的量法 只量到 314,572,800 與 209,715,200 兩個值, 中間沒有第三個(這一項重跑三趟都只有兩個值)。 複製途中送中斷訊號各試 21 次:按 Ctrl-C 有 4 次 留下 0 位元組的遊戲檔(這個次數同樣每次不一樣, 同一台機器重跑一批是 3 次,差別只在訊號落在哪一刻);直接強制結束行程(模擬斷電或當機)21 次 全部留下 0 或半截。.packbak 不會被刪,再跑一次補得回來, 但那一刻的遊戲檔已經是壞的。
檔尾那 16 行 assert 每次執行都會跑一次雙向自我測試: 該放行的要放行,該擋的一個都不能漏。 12 行是路徑安全,4 行是備份完整性那幾個判斷。 雙向的用意是防「把規則寫太嚴」。 其中一條專門確認 data/CONFIG.dat 不會被誤殺, 因為 CONFIG 不是保留裝置名。 另外還有一個不碰遊戲檔的完整版:python3 -I mvp_pack.py --selftest 會在系統暫存區造一份假的遊戲資料夾,替守門下餌 (本站 2026-10-30 跑過:陰性對照 3 項加 29 個餌全綠,跑完就刪 —— 這兩個數字 就是 --selftest 跑完印出來的那一行, 你自己跑一次可以對;Python 3.7 少兩個跟中斷訊號有關的餌,印的是 27;Windows 上開著的檔通常換不掉名,模擬「預覽之後包被換掉」的餌 25 會印「跳過」,數字再少一個(這是依 Windows 的行為推論的,還沒在 Windows 上實跑)。 這一天新增的 7 個餌管程式碼、副檔名的各種寫法、MZ、壓縮炸彈、 三個大小上限、size 對不上、控制字元;本站另外把這幾道守門各拆掉一次 (27 種拆法)再跑,自我測試全部變紅。同一天稍後補的兩個餌(餌 25 預覽之後包被換成壓縮炸彈、餌 26 預覽列的 SHA-256 要等於裝進去的那一份)也各拆掉守門驗過,8 種拆法全部變紅;再補的餌 27(EA 原廠 webhelp.jar 的例外,自我測試不帶 EA 的檔,所以測試裡把已知清單暫時換成一筆假原廠檔的雜湊)也拆掉 11 種守門驗過,全部變紅)。 但不是每一道守門都有餌:本站把自我測試以外、條件成立就讓腳本停下來的 23 道守門逐一拆掉再跑自我測試,會變紅的只有 3 道(打包時讀回來的雜湊不對、舊備份壞了不敢往上蓋、還原時有檔沒還原成功就回 1); 其餘 20 道拆掉照樣全綠,包括 --install 時「包裡的檔跟 manifest 寫的 SHA-256 對不上,不敢裝」那一道 (腳本本身會擋,上面「別的工具打的包」那一段也實跑過,但自我測試還沒有替它下餌)。 ⚠️ 這組 23/3/20 是 2026-09-24 量的,2026-10-30 新增的那幾道守門沒有重量一次。

做不到的事也寫在腳本檔頭:看不到別的工具改的東西、不掃描包裡的內容、 不處理相依與版本衝突(兩個模組動到同一個檔,後裝的就贏, 但 .packbak 保留的仍然是最早那一份), 而且 .packbak 擋得住「裝了不喜歡」與「寫到一半失敗」(後者會自動退回), 擋不住「裝到一半斷電」 —— 那會留下一套半新半舊的遊戲, 自動退回本身也可能被斷電打斷。還有一件驗不到的: --restore 驗得出備份是不是半截的, 但驗不出備份的內容對不對 —— 一份完整無誤、卻是別人遊戲的 attrib.dat,它照樣會蓋回去。

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

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

"""
mvp_pack.py —— 把你改好的東西打包成一包,給別人裝。

    python3 -I mvp_pack.py "<遊戲資料夾>" --scan
    python3 -I mvp_pack.py "<遊戲資料夾>" --build 台灣之光.mvpmod --name "台灣之光" --author "你的名字"
    python3 -I mvp_pack.py "<遊戲資料夾>" --preview 台灣之光.mvpmod
    python3 -I mvp_pack.py "<遊戲資料夾>" --install 台灣之光.mvpmod          ← 預覽
    python3 -I mvp_pack.py "<遊戲資料夾>" --install 台灣之光.mvpmod --apply  ← 真的裝
    python3 -I mvp_pack.py "<遊戲資料夾>" --install 含程式的包.mvpmod --apply --allow-code
                                                  ← 包裡有 .exe / .dll 這類程式才需要
    python3 -I mvp_pack.py "<遊戲資料夾>" --restore
    python3 -I mvp_pack.py --selftest                       ← 只測自己,不碰遊戲

【怎麼知道你改了什麼】
本站的課程腳本在第一次寫入之前都會留一份備份,各課用各課的副檔名
(.playerbak / .facebak / .feltoolbak …)。2026-10-30 重數 site/tutorials/ 底下的
原始碼:會留備份的課程腳本有 25 支(散在 24 課),一共產生 25 種副檔名;
下面 BAK_SUFFIXES 那份清單剛好 25 種全部認得,一種都沒漏。
⚠️ 2026-09-03 數的是 24 支 / 23 課 / 23 種,那組數字在 2026-09-04
   「關掉開場動畫」那一課上線之後就過期了(它留的是 .introbak),
   清單當時漏收了它。2026-09-05 補進清單並重數(24 種)。
⚠️ 2026-10-29「換打擊姿勢與投球姿勢」那一課改留專屬的 .stancebak,清單一度沒收它
   (發布前的站內檢查先報紅,沒有漏收的版本公開過),2026-10-30 補進清單並重數:
   24 種變 25 種,支數與課數不變(25 支 / 24 課)。
清單裡沒有 .packbak —— 那是這支自己幫你裝別人的模組時留的(寫在 INSTALL_BAK),
不算「你改過的東西」。多一課會寫檔的教學,通常就多一種副檔名,這個數字會長。
所以「哪些檔旁邊有備份」就等於「你用本站工具改過哪些檔」——
不需要跟原版比對,也不需要你自己記得改過什麼。

⚠️ 這個方法只看得到**本站工具**留下的備份。你用別的工具改的、
   手動覆蓋的,它看不到 —— 那些要用 --add 自己指定。

【格式】
.mvpmod 就是一個 ZIP:
    manifest.json   包的描述 + 檔案清單 + 每個檔的 SHA-256
    files/          你改過的檔,照遊戲資料夾的相對路徑擺
格式很單純,所以別的工具照這個格式打出來的包,這一支一樣讀得懂。
⚠️ 但讀得懂不代表驗得到:雜湊要由打包的那一方寫進 manifest。
   班主任自己另有一支沒有公開的打包工具就沒寫:它放進檔案清單的每一項只有
   path / category / size 三個欄位,整支程式一次都沒有算 sha256。
   那種包拿來 --install,一個雜湊都不會被比對,只會過路徑檢查。
   本站在暫存資料夾拿一個沒寫 sha256 的包實跑過,確認照樣裝得進去。

【輸入與輸出】
--scan      輸入 = 遊戲資料夾。輸出 = 螢幕上的清單。不寫任何檔。
--build     輸入 = 遊戲資料夾(加上 --add 額外指定的相對路徑)。
            輸出 = 一個 .mvpmod 檔,寫在你指定的位置;遊戲資料夾一個位元組都不動。
            包的描述靠 --name(必填)、--author、--version(不給就是 1.0)、--note
            這四個,它們會寫進 manifest.json —— 別人 --preview 的時候看到的就是它們。
            ⚠️ 輸出路徑會先被擋一次:那個位置已經有檔案的話,只有「副檔名是
               .mvpmod 而且不在遊戲資料夾裡面」才准蓋過去(重打同一包的正常情形)。
               其餘一律停下來,不寫。
--preview   輸入 = 遊戲資料夾 + 一個 .mvpmod。輸出 = 螢幕上的清單。不寫任何檔。
--install   同上。不加 --apply 就等於 --preview;加了 --apply 才會寫進遊戲資料夾,
            並把每個被蓋掉的檔備份成「同名 + .packbak」,
            外加一張「同名 + .packbak.json」的收據(那份備份的大小與 SHA-256,
            --restore 靠它判斷備份完不完整)。
            ⚠️ 2026-10-30 起,包裡只要有「會執行的程式碼」,--preview 與 --install
               都會把它們另外列成一塊(名稱、大小、SHA-256),而且預設不裝,
               結束碼 1、遊戲檔一個位元組都不動。讀者看過、確定要裝,才加 --allow-code。
               之前能直接裝的包(例如附了 mvp2005.exe 的),現在要多加這一個旗標。
--restore   輸入 = 遊戲資料夾。把所有 .packbak 蓋回去,備份本身留著不刪。
            蓋回去之前每一份備份都要先過完整性檢查(見安全網第 6 條),
            檢查沒過的、以及「驗不出來的」那幾份都不蓋,
            那幾個遊戲檔一個位元組都不動,整支的結束碼是 1。
            加 --allow-unverified 才會連「驗不出來的」也蓋回去。
--selftest  不吃遊戲資料夾。在系統暫存區自己造一份假的,替守門下餌測一次
            (見檔尾 _selftest),跑完就刪。全綠回 0。
            ⚠️ 不是每一道守門都有餌。2026-09-24 把 _selftest 以外、條件成立就讓
               這一支停下來的 23 道守門逐一拆掉再跑,只有 3 道會讓自我測試變紅
               (打包時讀回來的雜湊不對、舊備份壞了不敢往上蓋、還原時有檔沒還原成功就回 1);
               其餘 20 道拆掉照樣全綠,包括 --install 時「包裡的檔跟 manifest 寫的
               SHA-256 對不上就不裝」那一道。
            ⚠️ 不可以加 python 的 -O:那個旗標會把 assert 整句拿掉,
               檔尾那十幾行「每次執行都會跑」的檢查一句都不會執行,
               測試就變成假綠燈。加了 -O 跑這一項會直接停下來、結束碼 2。
               (2026-09-06 之前不會停:python3 -O … --selftest 照樣印全綠回 0。)

第一個參數永遠是**遊戲資料夾**(裡面有 data 那一層),不是某個檔案。
開頭會先確認 data/database/attrib.dat 在不在,指錯地方當場就會講。

【安全網】
1. 預設不寫。--install 沒有 --apply 就只是列清單。
2. 打包完立刻讀回來驗:把包裡每個檔的 SHA-256 跟 manifest 寫的比一次,
   對不上就當場停下來報錯。
   ✅ 這一驗排在改名**之前**(2026-09-05 第二輪改的):驗的是還沒改名的
      暫存檔,沒過就把暫存檔刪掉,你指定的那個位置**不會生出任何東西**,
      也就沒有「一個壞包躺在那裡等你誤傳給別人」這回事。
      (第二輪之前是先改名再驗,驗不過會留下一個完整、打得開、卻跟 manifest
       對不上的包,而且腳本不會替你刪。)
3. 安裝分三個階段(讀 → 試寫 → 真的寫):先把整包讀進記憶體,全部讀完才動第一個檔,
   所以不會裝到一半才發現包是壞的。讀的時候會比對雜湊,但**只比對
   manifest 上有寫 sha256 那一欄的項目**;沒寫的一個都不驗,照樣寫進
   遊戲資料夾。本腳本 --build 打的包每個檔都有 sha256,別的工具打的
   就不一定(見【格式】那一段)。
   讀完之後、動第一個檔之前還有兩道:
     (a) 這個檔旁邊已經有 .packbak 的話,先驗那份舊備份是不是完整的。
         它是你退回原狀的唯一依靠,壞的話不敢再往上蓋 —— 整支停在這裡,
         一個檔都還沒有被改到。(第二輪之前完全不驗,於是你會在
         「已經沒有退路」的狀態下又蓋了一層新的,而且沒有人講。)
     (b) 每一個要寫的位置都先在它旁邊建一個**名字猜不到的**暫存檔再刪掉,
         確認真的寫得進去。這一關擋的是「遊戲裝在
         C:\\Program Files 而你沒有用系統管理員身分執行」與「檔案正被遊戲鎖住」
         這兩種最常見的失敗 —— 它們以前會讓你拿到一套半新半舊的遊戲。
   ⚠️ 這一關過了不代表後面一定寫得成(權限可以在中間被改掉、磁碟可以寫滿)。
      真的寫到一半失敗時,腳本會**自動退回**這一輪已經寫進去的那幾個檔
      (照 .packbak 一個一個蓋回去,原本不存在的檔就刪掉),
      再告訴你退了幾個、有沒有退不回去的,結束碼是 1。
      退回去的是**原始**的遊戲檔,跟你自己跑 --restore 的結果一樣;
      那個檔身上本來就有別的模組時,那一份也會一起退掉。
   ✅ 2026-09-06 第三輪起,「真的寫」那一段自己又分成兩半:先把每個檔的備份與
      **已經驗過的暫存檔**全部備妥(這一整段一個遊戲檔都沒有動),全部備妥了
      才一次把它們換上去,而且整批換名包在同一段不可中斷的程式裡。
      所以磁碟寫滿、權限中途被改掉這一類失敗,結果是「全部沒裝」而不是
      「裝了一半再退回去」;Ctrl-C 也不會落在兩個檔中間。
      (自動退回那條路留著:換名途中真的有一次失敗,還是照 .packbak 退回去。)
      ✅ 失敗訊息也照這兩半分:一個檔都還沒換過就失敗,第一句是
         「還沒開始換檔就失敗了」,而且會明講「遊戲檔一個位元組都沒有動」;
         真的換到一半才失敗才會說「寫到一半失敗了」。分辨的依據是**登記的狀態**
         不是計數器 —— 中斷或例外可以剛好落在「換完了、還沒記到」那一瞬間。
   ⚠️ 中途按 Ctrl-C:結束碼是 130,而且會告訴你**有沒有**檔案已經被換掉、
      換了哪幾個。第二輪之前不管有沒有動到檔都印「中止了」然後結束碼 0 ——
      批次檔看到 0 會把一套半新半舊的遊戲當成裝好了。
      ✅ 第三輪再補一道:「換名 + 登記」現在包在一段**不可中斷**的程式裡,
         那一段收到 Ctrl-C 會先記著、離開之後才丟出來,所以不會出現
         「檔案換掉了,收尾卻說一個都沒動」,也不會出現反過來的
         「什麼都沒換,卻說換了一個」(第二輪是後面這一種)。
         萬一連那一道都裝不上(非主執行緒之類),還有第三個狀態接住:
         收尾會照實說「中斷的那一刻正在替換 X,換成了沒有這裡答不出來」。
4. 每一個路徑都要過 safe_target():組出絕對路徑之後還要確認它真的落在
   遊戲資料夾底下,逃出去的一個都不裝。
5. 打包(--build)、安裝(--install --apply)與還原(--restore)三條路都是
   先寫暫存檔、fsync、**讀回來比對 SHA-256**、通過了才 os.replace 換上去,
   都不留半截檔;讀回來對不上就把暫存檔丟掉,正本一個位元組都不動。
   ⚠️ 暫存檔的名字由 tempfile.mkstemp 產生(像 .attrib.dat.install-8p3k2q1x),
      不是「目的檔 + .part」。2026-09-05 第二輪改的,理由是那種名字**猜得到**:
      先在旁邊擺一個叫 <目的檔>.part 的符號連結指到別的地方,
      腳本自己開檔的那一刻就會跟著連結把外面那個檔截成 0,
      等到 os.replace 只換掉連結本身,外面的檔已經回不來了。
      Path.exists() 擋不住這一招(指向不存在目標的連結在它眼裡是 False),
      mkstemp 擋得住:名字是隨機的,而且它內部用 O_CREAT|O_EXCL 開檔。
      目的檔與備份檔本身則另外用 os.path.islink 問一次,是連結就整支停下來。
      ⚠️ 「要蓋掉的那個遊戲檔本身是連結」這一道是 2026-09-06 第三輪才補的。
         在那之前 --install --apply 會照著連結寫到它指的那個檔去
         (實測:回 0、印 ✅,被改掉的是連結指向的檔,連結本身原封不動)。
         現在照你打的那個名字問一次 islink,是連結就停下來;
         路徑**中間**的資料夾是連結沒關係(有人把遊戲放在別顆碟),只看最後那一段。
      換名那一步(還有緊接著的登記)包在一段不可中斷的程式裡,見安全網第 3 條。
   ⚠️ --restore 以前不是這樣。2026-09-05 之前它用 shutil.copy2 直接蓋回遊戲檔,
      下面這一段是當時量到的樣子,留著說明為什麼要改:本站在這台 Mac(APFS)
      上拿一個 300 MB(314,572,800 bytes)的測試檔當遊戲檔,配一份同樣大的
      .packbak 跑 --restore,每 5 毫秒量一次遊戲檔的大小,量到
      314,572,800 → 0 → 1,048,576 一路長回 314,572,800,
      一趟裡出現 22 個不同的大小(這個數字每次跑都不一樣,重點是它不只一個);
      同一份檔改走 --install --apply(300 MB 換成 200 MB),同樣的量法只量到
      314,572,800 與 209,715,200 兩個值,中間沒有第三個。
      也就是說遊戲檔本人會先被清成 0,再一段一段長回去。複製途中送中斷訊號
      各試 21 次:按 Ctrl-C 有 4 次留下 0 bytes 的遊戲檔,其餘來得及跑完
      (這個次數跟上面那個一樣每次跑都不一樣,同一台機器重跑一批是 3 次,
       差別只在訊號落在哪一刻);
      直接強制結束行程(模擬斷電或當機)21 次全部留下 0 或半截
      (例如 51,380,224 bytes)。
      .packbak 不會被刪,再跑一次 --restore 補得回來,但那一刻的遊戲檔是壞的。
      改成先複製到一個暫存檔、再 os.replace 之後,2026-09-05 在同一台機器上
      用同樣的量法重跑一次(這次是 150 MB):遊戲檔只量得到
      104,857,600(舊的)與 157,286,400(新的)兩個大小,14 個取樣點裡沒有第三個。
      ⚠️ 那一次量的版本用的暫存檔名還是 orig + '.part';同一天第二輪把它換成
         tempfile.mkstemp 給的隨機名字(理由見第 5 條),原子性沒有改變,
         上面那組數字照樣成立。改法就寫在 atomic_copy_over 與 cmd_restore 裡。

6. --restore 蓋回去之前,每一份 .packbak 都要先過完整性檢查:
   · 0 bytes 的不用(複製到一半被中斷的多半長這樣)。
   · 有收據的比對收據:--install --apply 留備份的同時會在旁邊寫一個
     「同名 + .packbak.json」,裡面是那份備份的大小與 SHA-256。
     對不上就不蓋 —— 這一關擋得住任何形式的半截與竄改,不只 0 bytes。
   · 沒有收據的(舊版腳本留下的備份、或收據被刪了)退而求其次看格式:
     BIGF 的檔頭第 5-8 個位元組寫著整包該有多大(⚠️ 這個數字有的檔存成小端、
     有的存成大端,所以兩種都算,只有「比兩種都小」才叫半截 —— 詳見
     truncation_evidence 的說明),PE(MZ 開頭的執行檔)
     的最後一個區段算得出檔案至少該有多大,純文字檔比對「原檔以換行結尾
     而備份沒有」。抓到其中一種就是明確的半截,不蓋。
   · 備份檔本身是符號連結、或者要蓋回去的遊戲檔是符號連結:兩種都不蓋。
     那代表讀到的、寫到的都不是這個資料夾裡的東西,情況太特別,交給你自己處理。
   · 沒有收據、而格式又抓不到證據時(可能是它真的完整,也可能是這個格式
     驗不出來,例如一個沒有收據的 .fsh),**預設不蓋**,那個遊戲檔維持原狀,
     結束碼 1,並且告訴你怎麼放行。驗不出來就當成不能信:什麼都不做,
     永遠比把一份可能是半截的東西蓋到遊戲檔上安全。
     你自己確定它是完整的,加上 --allow-unverified 再跑一次,
     結尾的統計會把這幾個跟驗過的分開數。
     (2026-09-05 第二輪之前是照樣蓋回去只印一行 ⚠️。)
   ⚠️ 這裡刻意**不用**「不得小於原檔一半」那種地板。本站別的工具可以用,
      因為它們是就地改幾個位元組,備份跟現在的檔大小差不多。這一支不行:
      它裝的是別人給的檔,新檔可以比原檔大好幾倍也可以小好幾倍,
      拿現在的檔當尺會把正常的還原也擋掉。

7. 會執行的程式碼預設不裝(2026-10-30 加)。判斷有兩個,命中任何一個就算:
   · 檔名最後的副檔名是下面 34 種之一(2026-10-30 從十一種補齊):
     .exe .dll .asi .bat .cmd .com .ps1 .vbs .js .scr .lnk
     .hta .wsf .wsh .vbe .jse .url .scf .msi .msp .reg .jar .application .appref-ms .gadget
     .pif .cpl .ocx .sys .inf .command .sh .py .pkg
     判斷前先做這幾件事,免得換個寫法就繞過去:全部轉小寫、全形轉半形、
     拿掉看不見的控制字元、拿掉結尾的空白與點(Windows 會替你吃掉)、
     冒號切開每一段都看(NTFS 的另一條資料流 x.dll:abc)。
     只看**最後**一個副檔名,所以 readme.txt.exe 命中,x.exe.txt 不命中。
   · 內容開頭兩個位元組是 MZ(Windows 執行檔的標頭),不管檔名叫什麼。
     這一條可能誤中剛好以 MZ 開頭的資料檔,所以命中的一律**列出來**給你看,
     不是安靜地拒絕;你確定它是資料檔,同樣加 --allow-code 放行。
   命中的項目另外列成「🔴 會執行的程式碼」一塊:名稱、大小、命中的原因、SHA-256。
   沒加 --allow-code 的話整包不裝(不是只略過那幾個),結束碼 1,遊戲檔一個位元組都不動。
   換掉 .exe / .dll 等於讓做這一包的人在你的電腦上跑程式,所以這一道的預設是拒絕。
   ⚠️ 副檔名只列這 34 種。.msix .appx .psm1 .chm .msc .iso 這類也可能跑程式,
      但不在清單裡(沒加進來,不是確定安全)。.jar 在清單裡,連遊戲自己的
      support/EA Help/webhelp.jar 也算。
   一個例外:EA 原廠的 webhelp.jar。要同時符合兩件事才略過:路徑的最後一段是 webhelp.jar,
   而且整個檔的 SHA-256 等於本檔 KNOWN_EA_FILES 記的 EA 原廠那一份。命中的不當成會執行的程式碼,
   但畫面照列一行「已知的 EA 原廠檔,略過」(名稱、SHA-256)。只符合一件的照樣擋:
   同名但內容不同(有人把別的 jar 取名 webhelp.jar)、內容一樣但改了名字,都還是程式碼。

8. 壓縮檔的大小上限與畫面上的控制字元(2026-10-30 加):
   · 打開 .mvpmod 之後、讀任何一個項目的內容之前,先用壓縮檔目錄上寫的
     file_size / compress_size 檢查:單項解開超過 1 GB、整包超過 4 GB、
     壓縮比超過 200 倍、manifest.json 超過 1 MB,都不讀(壓縮炸彈:幾十 KB 的包
     解開是幾 GB,讀進記憶體整台電腦會卡死)。python 的 zipfile 讀一個項目最多
     讀到目錄上宣告的大小為止,所以這個宣告的數字就是記憶體的上限。
   · 整個 --preview / --install 只開一次 .mvpmod(2026-10-30 再補,檢查後替換):
     manifest、目錄、大小上限、認 MZ、SHA-256、讀內容全部用同一個開著的檔案,不依路徑重新開。
     以前讀內容那一段會重新開一次,兩次之間這個檔被換成壓縮炸彈的話,
     第二次開到的沒有人再查過上限就整個解壓。--install 另外把預覽替程式碼項目列在畫面上的
     SHA-256 記下來,讀內容時算出來要一模一樣,不一樣就整包不裝(--allow-code 放行的是畫面上核對過的那一份)。
   · 預覽顯示的大小是壓縮檔裡**實際**的大小,不是 manifest 自己寫的。
     兩邊不一樣就標 🔴,整包不裝;manifest 的 size 不是整數也一樣,講明白、不噴 traceback。
   · 所有從包裡、從遊戲資料夾的檔名讀出來要印到畫面上的字串(包名、作者、
     版本、說明、路徑),都先過 clean_text():控制字元(包含 ESC、換行)換成
     \\x1b 這種看得見的寫法,太長的截斷。不然包的作者可以在說明欄塞游標移動的
     跳脫序列,把真正的檔案清單蓋掉、偽造一行「✅ 都沒問題」。
     is_safe_relpath() 也拒絕任何控制字元的路徑。

【做不到的事】
· 看不到不是本站工具改的東西。判斷依據只有「旁邊有沒有本站的備份副檔名」,
  你用 TiT、MVPEdit 改的或手動覆蓋的,一個都不會被收進去,那些要 --add 自己指定。
· 不看包裡的內容。沒有病毒掃描,也沒有任何內容審查。
  唯一的例外是上面安全網第 7 條:檔名或開頭看起來是程式的,會被攔下來讓你決定。
  那一條只是看副檔名與開頭兩個位元組,攔不住藏在資料檔裡的東西。
  路徑安全不等於檔案安全:一個位置完全正確的 attrib.dat 可以把你的名單改成任何樣子。
  只裝你認識的人給你的包。
· 換掉 .exe / .dll 等於讓那個人在你的電腦上跑程式。--allow-code 不是「確認一下」,
  是「我信這個人」。裝了之後 .packbak 可以把檔案還原,但程式只要跑過一次就已經跑過了。
· .packbak 只擋得住「裝了不喜歡」與「寫到一半失敗」(後者會自動退回),
  擋不住「裝到一半**斷電**」—— 那會留下一套半新半舊的遊戲
  (已經寫進去的檔是新的,還沒輪到的是舊的),要靠 --restore 退回去。
  自動退回本身也可能被斷電打斷。裝之前自己另外留一份備份。
· --restore 驗得出備份是不是半截的,但驗不出備份「內容對不對」:
  一份完整無誤、卻是別人遊戲的 attrib.dat,它照樣會蓋回去。
· 不處理相依關係,也不處理版本衝突。兩個模組動到同一個檔,後裝的就贏
  (.packbak 保留的仍然是最早那一份,所以 --restore 還是回得到原始狀態)。
· --restore 只認得這支工具自己留下的 .packbak,不會去動別課留下的備份。

法律與免責
    本教學與本腳本與 Electronic Arts 無任何官方關聯,不含 EA 的程式碼或資產,
    也不含任何規避技術保護措施的功能。僅供你對**自己合法取得的副本**使用,
    風險自負。本軟體按現狀提供,不附任何擔保。
    ⚠️ 你打包出來的那一包裡面**是 EA 的遊戲檔案**(被你改過的)。
       要不要把它給別人,是你自己的判斷與責任,本站不提供散布管道。

—— toni的MVP模組補習班
"""
TOOL_DATE = '2026-10-30.7'  # 這一版工具的日期(同一天第 N 次改寫加 .N)

import argparse
import hashlib
import json
import os
import shutil
import signal
import sys
import tempfile
import unicodedata
import zipfile
import zlib

# Windows 主控台預設編碼(繁中是 cp950)存不下 ⚠️ ✅ 🔴 這類符號,
# 輸出被導向檔案或管線時會整支以 UnicodeEncodeError 收場,連 -h 都印不完。
# 先把輸出轉成 UTF-8。
try:
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except (AttributeError, ValueError):
    pass

BAK_SUFFIXES = (
    '.audiobak', '.autobak', '.bak', '.chantbak', '.datafilebak',
    '.exepebak', '.facebak',
    '.facetexbak', '.feltoolbak', '.fontbak', '.hudcolorbak',
    '.introbak', '.locbak',
    '.modernizebak',
    '.logobak', '.menutextbak', '.playerbak', '.portraitbak', '.ratingsbak', '.screenbak',
    '.shrinkbak',
    '.speedbak', '.stancebak', '.unibak', '.unpackbak',
)
# ⚠️ 這份清單漏一個就會靜默漏檔:少了某個副檔名,那一課改過的檔案就不會被
#    當成「你改過的東西」,打包出來的模組少了它,而且不會有任何提示。
#    踩過三次,差一點第四次:
#      · 2026-08-29 漏了 .ratingsbak,跑過能力值換算的人打包時 attrib.dat 不會進去。
#      · 2026-09-03 漏了 .exepebak(「執行檔上的六個旋鈕」那一課留的)。做一個假的
#        遊戲資料夾實測:mvp2005.exe 旁邊放 mvp2005.exe.exepebak,--scan 只印得出
#        attrib.dat 那一列,改過的執行檔整個不出現;補進清單之後同一個資料夾印出兩列。
#      · 2026-09-05 漏了 .introbak(「關掉開場動畫」那一課 2026-09-04 上線時留的)。
#        同樣的假資料夾實測:intro.vp6 旁邊放 intro.vp6.introbak,--scan 只印得出
#        attrib.dat 一列;補進清單之後印出兩列。下面那道站內檢查從那一天起就一直在報紅,
#        只是發布前的總檢查當時沒有跑它。
#      · 2026-10-30 差一點漏了 .stancebak(「換打擊姿勢與投球姿勢」那一課 2026-10-29 改留
#        專屬備份,以前它留的是通用的 .bak)。站內檢查在發布前就報紅,沒有漏收的版本上線過;
#        不補的話,做過那一課的人打包時,改過的 attrib.dat / pitcher.dat 不會進模組包。
#    本站發布前另有一道檢查守著它(那是站內自用的檢查,不在下載包裡):把各課腳本
#    用引號寫出的備份副檔名(像 '.speedbak')跟這份清單對一次。但它配不到通用的 .bak,
#    也不看 pack-a-mod、backup、crash-diagnosis 三課,那幾種少了它發現不了。
#
# 補充兩件讀者看不出來的事:
#   · 這裡面的 '.bak' 是通用副檔名(換賽程年份、關掉跑壘者速度數值那兩課留下的就是它)。
#     別的程式留下的 .bak 也會被當成「你改過這個檔」。這是刻意寧可多收不要漏收,
#     所以 --scan 印出來的清單值得自己看一眼。
#   · find_changed() 只要配到第一個吻合的副檔名就停,不再往下試,
#     用意是「一個檔名只算一種備份」。但這份清單目前的排列順序不影響結果:
#     在這份 mvp_pack.py 上把清單裡的 25 個項目兩兩互測 a.endswith(b),0 組重疊
#     (每個都以點開頭、中間又沒有第二個點,所以 '.chantbak' 不會吻合 '.bak'),
#     任何檔名最多只配得到一個,重排這個 tuple 掃出來的結果完全一樣。
#     25 是這份清單自己的長度(含通用的 '.bak')。上面那道站內檢查
#     印的「24 種」是另一個口徑:它的比對規則
#     抓不到 '.bak',兩個數字不是在數同一件事,不要互相對照。
#     ⚠️ 順序什麼時候才會開始有意義:哪天新增一個中間帶點的副檔名
#     (例如 '.big.bak'),它會同時吻合 '.bak',那時就要把長的排在短的前面,
#     不然 x.big.bak 會被當成「x.big 的備份」而不是「x 的備份」。
INSTALL_BAK = '.packbak'          # 這支自己裝東西之前留的備份
# 備份的收據:跟 .packbak 放在一起,裡面只有那份備份的大小與 SHA-256。
# --restore 靠它判斷備份是不是完整的(見 check_backup())。
# 名字刻意不以任何一個 BAK_SUFFIXES 結尾,免得 --scan 把收據也當成「你改過的檔」。
BAK_RECEIPT = INSTALL_BAK + '.json'
MANIFEST = 'manifest.json'

# ── Ctrl-C 的三個狀態 ───────────────────────────────────────
# 中斷的時候要跟玩家講的只有一件事:**檔案動到了沒有**。答案有三種,
# 三種要做的事完全不一樣,所以狀態也要有三個,不可以只有「有」跟「沒有」:
#   還沒動   兩個清單都是空的      → 「一個檔都沒有被動到」
#   正在換   _INFLIGHT 裡還有東西  → 「中斷的那一刻正在替換 X,換成了沒有不確定」
#   已換過   _MUTATED 裡有東西      → 「已經有 N 個檔被換掉」+ 叫他跑 --restore
# 2026-09-05 之前不管哪一種都印「中止了。」然後 exit 0 ——
# 批次檔看到 0 會當成裝好了。
# 2026-09-06 第三輪補上中間那一個狀態,理由見 _NoInterrupt 與 _swap。
_MUTATED = []     # os.replace 已經做完、而且登記也做完了的遊戲檔
_INFLIGHT = []    # 進了「換名 + 登記」那一段,但還沒確定結果的


class _NoInterrupt(object):
    """把「os.replace + 登記」包成一段**不可中斷**的程式。

    這一段期間收到 Ctrl-C 先記著,離開這一段之後再照常丟出去。
    所以 KeyboardInterrupt 的收尾看到的登記,一定跟磁碟上的狀態一致:
    不會出現「檔案已經換掉了,收尾卻說一個都沒動」(漏報,最糟的一種),
    也不會出現「什麼都沒換,收尾卻說換了一個」(多報,舊版是這一種 ——
    2026-09-06 實測:把訊號插在登記之後、os.replace 之前,檔案內容沒變
    而收尾說「已經有 1 個遊戲檔被換掉」)。

    ⚠️ 非主執行緒之類的情況裝不上訊號處理器(signal.signal 會丟 ValueError),
       那時就退回舊行為,不會比舊版更糟;真的在那個縫隙被打斷,
       由 _swap 的 _INFLIGHT 中間態接住,收尾照實說「正在替換」。
    """

    def __enter__(self):
        self._pending = False
        self._old = None
        try:
            self._old = signal.signal(signal.SIGINT, self._remember)
        except (ValueError, OSError):
            self._old = None
        return self

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

    def __exit__(self, exc_type, exc, tb):
        if self._old is not None:
            signal.signal(signal.SIGINT, self._old)
        # 這一段裡本來就出事了(例如 os.replace 丟 OSError)的話,
        # 讓真正的死因往外走,不要用 KeyboardInterrupt 把它蓋掉。
        if self._pending and exc_type is None:
            raise KeyboardInterrupt
        return False


def _swap(tmp, dst, record=False):
    """換名 + 登記。**呼叫的人要把它包在 _NoInterrupt 裡面**,不然中間有縫。

    先把 dst 登記成「正在換」,結果確定了才把它拿掉:
      · os.replace 丟 OSError → 確定沒換成:拿掉,而且不記進 _MUTATED
      · 換完、也登記完了      → 確定換成了:拿掉
      · 兩者都不是            → 留在 _INFLIGHT。只有在 _NoInterrupt 裝不上
        訊號處理器的時候才可能走到這裡,收尾會照實說「正在替換」。
    """
    settled = False
    _INFLIGHT.append(dst)
    try:
        try:
            os.replace(tmp, dst)
        except OSError:
            # 只有 OSError 才確定「這一次 rename 沒有發生」。
            settled = True
            raise
        if record:
            _MUTATED.append(dst)
        settled = True
    finally:
        if settled:
            _INFLIGHT.remove(dst)


def _commit(tmp, dst, record=False):
    """換一個檔:自己開一段不可中斷段,把 _swap 包起來。

    多檔一起換的地方(cmd_install 的第三階段)不走這裡:它整批只開一段,
    直接呼叫 _swap,免得一段裡面再套一段。
    """
    with _NoInterrupt():
        _swap(tmp, dst, record=record)


def report_interrupt():
    """Ctrl-C 的收尾:講清楚檔案動到了沒有。__main__ 與 --selftest 共用同一份。

    「中止了」這三個字不夠。要講的是**檔案動到了沒有**,
    因為這幾種狀況要玩家做的事完全相反。
    """
    # 已經確定換掉的就不必再列一次「正在換」。
    pending = [p for p in _INFLIGHT if p not in _MUTATED]
    print()
    print()
    if _MUTATED:
        print('  中止了,但是**已經有 %d 個遊戲檔被換掉**:' % len(_MUTATED))
        for p in _MUTATED[:10]:
            print('      %s' % clean_text(p, 300))
        if len(_MUTATED) > 10:
            print('      …另外還有 %d 個。' % (len(_MUTATED) - 10))
    if pending:
        if not _MUTATED:
            print('  中止了,而且中斷的那一刻**正在替換**這 %d 個檔:' % len(pending))
        else:
            print('  另外這 %d 個是中斷的那一刻**正在替換**的:' % len(pending))
        for p in pending[:10]:
            print('      %s' % clean_text(p, 300))
        if len(pending) > 10:
            print('      …另外還有 %d 個。' % (len(pending) - 10))
        print('  它們換成了沒有,這裡答不出來 —— 當成已經換掉處理比較安全。')
    if _MUTATED or pending:
        print('  現在這套遊戲是半新半舊的。跑一次 --restore 退回去。')
        if pending:
            print('  (或者自己拿旁邊的 %s 跟遊戲檔比對。)' % INSTALL_BAK)
    else:
        print('  中止了。一個檔都沒有被動到。')
    print()


class Stop(Exception):
    """帶著一句人話中止。"""


# ── 印到畫面上的不可信字串(2026-10-30 加)────────────────────
# 包名、作者、版本、說明、檔案路徑,都是別人寫進 manifest.json 的字串。
# 直接印到終端機的話,裡面的 ESC(\x1b)可以變成游標移動、清除整行、甚至寫入剪貼簿的
# 跳脫序列,把下面真正的檔案清單蓋掉,再偽造一行「✅ 每一個檔都沒問題」。
# 所以所有這類字串在印出去之前,一律經過同一個函式。
# 控制類別:Cc(C0 / DEL / C1 控制字元,含 ESC、換行、Tab)、Cf(格式字元,
# 例如把文字顯示方向反過來的 U+202E)、Cs(單獨的代理字元,印出時會讓編碼爆掉)、
# Zl / Zp(行分隔 / 段落分隔,有些畫面會當成換行)。
_CTRL_CATS = ('Cc', 'Cf', 'Cs', 'Zl', 'Zp')


def _esc(ch):
    o = ord(ch)
    if o <= 0xFF:
        return '\\x%02x' % o
    if o <= 0xFFFF:
        return '\\u%04x' % o
    return '\\U%08x' % o


def clean_text(value, limit=200, keep='\t'):
    """把要印到畫面上的字串洗乾淨:控制類字元換成 \\x?? 看得見的寫法,太長就截斷。

    · value 不是字串(manifest 裡的欄位可以是任何 JSON 型別)就先轉成 repr。
    · keep 是「原樣放行」的控制字元,預設只有 Tab。最外層的錯誤出口會再放行 \\n,
      因為我們自己寫的多行說明需要換行;不可信的欄位一律不放行。
    · 截斷發生在洗完之後,所以不會把一個 \\x1b 切成一半;截斷的地方補一句「共 N 字」。
    """
    s = value if isinstance(value, str) else repr(value)
    out, n = [], 0
    for ch in s:
        piece = ch
        if ch not in keep and unicodedata.category(ch) in _CTRL_CATS:
            piece = _esc(ch)
        if n + len(piece) > limit:
            out.append('…(共 %d 字)' % len(s))
            break
        out.append(piece)
        n += len(piece)
    return ''.join(out)


# ── 會執行的程式碼(2026-10-30 加)────────────────────────────
# 裝別人的包,等於讓那個人決定你的電腦要跑什麼。包裡放一個 mvp2005.exe、
# 一個代理用的 dinput8.dll、一個改過的 delete.bat,路徑完全合法、SHA-256 也對得上
# (雜湊是打包的人自己寫的),下一次開遊戲,攻擊者的程式碼就以你的身分在跑了。
# 所以另外把「會執行的程式碼」挑出來,預設不裝。
#
# ⚠️ 2026-10-30 複查指出:原本只列十一種,.hta 與 .wsf 這種文字型的可執行格式沒有 MZ 開頭,
#    兩道判斷都攔不住,等於沒加 --allow-code 就能裝。現在補到 34 種。
#    同一份清單在 mvp_autoinstall.py 裡有一份一模一樣的(兩支各自要能單獨下載使用,不能互相 import);
#    站內有一道檢查(selftest_code_policy.py)比對兩份,改一邊忘了另一邊會紅。
CODE_EXTS = (
    # 原本的十一種
    '.exe', '.dll', '.asi', '.bat', '.cmd', '.com', '.ps1',
    '.vbs', '.js', '.scr', '.lnk',
    # Windows 腳本與 shell 格式(.hta .wsf 是文字檔,沒有 MZ 開頭)
    '.hta', '.wsf', '.wsh', '.vbe', '.jse', '.url', '.scf',
    # 安裝程式、登錄檔、Java、ClickOnce、桌面小工具
    '.msi', '.msp', '.reg', '.jar', '.application', '.appref-ms', '.gadget',
    # 捷徑設定、控制台元件、控制項、驅動、安裝資訊
    '.pif', '.cpl', '.ocx', '.sys', '.inf',
    # Mac / Linux 雙擊或執行會跑的
    '.command', '.sh', '.py', '.pkg',
)


def code_ext_of(name):
    """檔名的最後一個副檔名屬於 CODE_EXTS 的話回傳它(例如 '.dll'),否則回 None。

    這一支只看字面,不碰檔案。判斷前先把「換個寫法就繞過去」的幾種抹平,
    每一種都是會讓 Windows 照樣當成程式、卻讓單純比對副檔名失效的寫法:
      · 大小寫與全形:X.EXE、x.exe(NFKC 轉半形後再 casefold)
      · 看不見的字元:x.e\\u200bxe(Cc / Cf 先拿掉)
      · 結尾的空白與點:x.exe 、x.exe.(Windows 建檔時會默默吃掉,實際檔名是 x.exe)
      · NTFS 的另一條資料流:x.dll:abc、x.exe::$DATA(冒號切開,每一段都看;
        也順便抓到 C:evil.exe 這種磁碟機代號後面接檔名的寫法)
      · 資料夾:只看最後一段,兩種斜線都算
    只看**最後**一個副檔名:readme.txt.exe 命中(Windows 的檔案總管預設把 .exe 藏起來,
    看起來像 readme.txt);x.exe.txt 不命中(它就是一個文字檔)。
    """
    if not isinstance(name, str):
        return None
    s = unicodedata.normalize('NFKC', name)
    s = ''.join(c for c in s if unicodedata.category(c) not in ('Cc', 'Cf'))
    s = s.replace('\\', '/').split('/')[-1]
    for piece in s.split(':'):
        piece = piece.rstrip(' .').casefold()
        for ext in CODE_EXTS:
            if piece.endswith(ext):
                return ext
    return None


def code_reason(name, head=None):
    """這個檔為什麼算「會執行的程式碼」。不是的話回 None。

    name 是檔名或相對路徑;head 是檔案開頭的位元組(給 None 就只看檔名)。
    兩個條件任何一個成立就算:副檔名在 CODE_EXTS 裡、或內容開頭是 MZ。
    ⚠️ MZ 這一條可能誤中剛好以 MZ 開頭的資料檔,所以呼叫的人要把命中的列出來,
       不可以安靜地拒絕(見 cmd_preview)。
    """
    why = []
    ext = code_ext_of(name)
    if ext:
        why.append('副檔名 %s' % ext)
    if head is not None and head[:2] == b'MZ':
        why.append('內容開頭是 MZ(Windows 執行檔的標頭)')
    return '、'.join(why) or None


# ── 已知的 EA 原廠檔(2026-10-30 加)────────────────────────────
# .jar 在 CODE_EXTS 裡,遊戲自己的 support/EA Help/webhelp.jar(EA 原廠的說明檔)因此被判成程式碼,
# 想換回原廠那一份也得加 --allow-code。這裡開一個很窄的例外,而且不能只憑檔名:
# 任何人都可以把惡意的 jar 取名 webhelp.jar。所以要「路徑最後一段是這個名字」
# 而且「整個檔的 SHA-256 等於下面記的 EA 原廠版本」兩件事同時成立;
# 同名但內容不同、內容一樣但改了名字,都不算(仍是程式碼)。
# 每一筆是 (檔名, SHA-256, 說明)。同一份清單在 mvp_autoinstall.py 裡有一份一模一樣的
# (兩支各自要能單獨下載使用,不能互相 import);站內有一道檢查(selftest_code_policy.py)比對兩份,
# 改一邊忘了另一邊會紅。
KNOWN_EA_FILES = (
    # webhelp.jar:EA 原廠的說明檔,英文版放在 support/EA Help/,中文版放在 Support/European Help Files/。
    # 下面五份安裝實際量過,逐位元組相同(所以只有這一筆):
    #   · 英文版原版剛安裝好的一份         Support/EA Help/webhelp.jar
    #   · 中文版原版剛安裝好的一份         Support/European Help Files/webhelp.jar
    #   · 另外兩份修補或中文化後的安裝     Support/European Help Files/webhelp.jar
    #   · 現行遊戲資料夾                   support/EA Help/webhelp.jar
    ('webhelp.jar',
     '179a02f737383cfe8b66ea6970ab03baa3ce7325632d4d5d405cbee0a53d31b3',
     'EA 原廠說明檔 webhelp.jar'),
)


def _ea_name_tail(name):
    """路徑的最後一段,抹平大小寫、全形、看不見的字元與兩種斜線。不是字串回 None。

    跟 code_ext_of 不一樣的地方:結尾的空白與點、冒號後面接的資料流**不**抹平 ——
    webhelp.jar 後面多了任何東西,就不是原廠那個檔名,一律不算已知檔。
    """
    if not isinstance(name, str):
        return None
    s = unicodedata.normalize('NFKC', name)
    s = ''.join(c for c in s if unicodedata.category(c) not in ('Cc', 'Cf'))
    return s.replace('\\', '/').split('/')[-1].casefold()


def known_ea_file(name, sha256):
    """name 的最後一段與整個檔的 SHA-256 都對得上 KNOWN_EA_FILES 的某一筆,回那一筆的說明;否則回 None。

    sha256 是整個檔(不是開頭)的十六進位雜湊。兩個條件缺一不可:
    只有名字對(內容被換過)、只有內容對(改了名字)都回 None。
    """
    tail = _ea_name_tail(name)
    if tail is None or not isinstance(sha256, str):
        return None
    got = sha256.lower()
    for want_name, want_sha, note in KNOWN_EA_FILES:
        if tail == want_name and got == want_sha:
            return note
    return None


# ── 路徑安全 ────────────────────────────────────────────────
# 這一段的判斷,當初是照班主任自己另一支沒有公開的打包工具寫的,
# 但補上一件當時它的說明寫著有、程式碼裡沒有的:**Windows 保留檔名**。
# 2026-08-29 實測那支:CON / PRN / AUX / COM1 / LPT1 一個都沒擋。
# ✅ 2026-08-30 那一支已經照這一支的做法補上同一份清單。
#    上面那句留著記錄當時量到的事實。
WIN_RESERVED = {
    'con', 'prn', 'aux', 'nul',
    *('com%d' % i for i in range(1, 10)),
    *('lpt%d' % i for i in range(1, 10)),
}


def is_safe_relpath(rel):
    """拒絕:絕對路徑、父層跳脫、控制字元(NUL / 換行 / ESC…)、磁碟機代號、~、Windows 保留檔名。"""
    if not isinstance(rel, str) or not rel:
        return False
    # NUL 會讓底層的 C 函式提早把字串當成結束:'data/x\x00../../evil'
    # 在 Python 眼裡是一整串,傳到某些系統呼叫卻只剩前半段。
    # 換行則會弄壞任何逐行處理的記錄與輸出。
    if '\x00' in rel or '\n' in rel or '\r' in rel:
        return False
    # 2026-10-30:其餘的控制字元一律拒絕,不只上面三個。ESC(0x1b)可以在預覽畫面上
    # 變成游標移動或清除整行,把真正的清單蓋掉;Tab、DEL(0x7f)、C1 控制字元、
    # 把顯示方向反過來的 U+202E 之類的格式字元也一樣。遊戲資料夾裡不會有這種檔名,
    # 所以不會誤殺。這一道讓「通過檢查的路徑,經過 clean_text() 之後(長度在上限以內的話)
    # 一個字都不會變」成立。
    if any(ord(c) < 0x20 or ord(c) == 0x7f or unicodedata.category(c) in _CTRL_CATS
           for c in rel):
        return False
    if os.path.isabs(rel):
        return False
    # 冒號一律拒絕,不是只看第二個字元。兩種東西都靠它:
    #   · 'C:/…' 磁碟機代號。在 Mac 上不算絕對路徑(上面那一道抓不到),
    #     在 Windows 上卻是。
    #   · 'data/x.dat:payload' 在 NTFS 上是 Alternate Data Stream(另一條資料流),
    #     不是一個普通的子檔案:寫下去會附在 x.dat 身上,而 safe_target() 那一道
    #     realpath 之後看到的仍然是遊戲資料夾裡面的路徑,擋不掉。
    #     2026-09-05 之前這裡只看 rel[1],冒號放在別的位置就整條放行。
    # 遊戲資料夾裡本來就不會有帶冒號的檔名(Windows 根本不准),誤殺的機會是 0。
    if ':' in rel:
        return False
    # '~' 本身不是路徑語法,但很多工具會替你展開,展開之後就跑到家目錄去了。
    if rel.startswith('~'):
        return False
    # 反斜線先統一成斜線,不然 Windows 寫法的 'data\..\..\evil'
    # 會整段被當成一個檔名,'..' 那一道就形同虛設。
    # '' 代表 'data//x' 這種空路徑段,各系統解讀不一致,一律拒絕。
    parts = rel.replace('\\', '/').split('/')
    if '..' in parts or '.' in parts or '' in parts:
        return False
    for p in parts:
        # Windows 的裝置名不管副檔名都算數:CON、CON.txt、con.dat 指的都是主控台,
        # 寫進去不會產生檔案。所以比對的是第一個點之前那一段。
        stem = p.split('.')[0].lower().rstrip(' ')
        if stem in WIN_RESERVED:
            return False
        if p != p.rstrip(' .'):        # Windows 會把結尾的空白與點吃掉
            return False
    return True


def safe_target(game_root, rel):
    """相對路徑 → 絕對 realpath;不合法或逃出遊戲資料夾就回 None。

    這是「寫進去之前的最後一道門」。上面的 is_safe_relpath() 看的是字面,
    這裡看的是**組合之後的真實落點**,兩道缺一不可。
    """
    if not is_safe_relpath(rel):
        return None
    # 兩邊都走 realpath,把符號連結在比較之前就解開。
    # 否則遊戲資料夾裡放一個指向別處的捷徑,組出來的路徑字面上還在資料夾裡,
    # 實際寫入卻會落到外面去。
    root = os.path.realpath(game_root)
    target = os.path.realpath(os.path.join(root, rel))
    try:
        # 用 commonpath 而不是字串 startswith:
        # startswith('/game') 會把 '/game2/x' 也算成在 '/game' 底下。
        if os.path.commonpath([root, target]) != root:
            return None
    except ValueError:
        # 兩條路徑不在同一個磁碟機(Windows 的 C: 與 D:)時 commonpath 會丟這個。
        # 不同磁碟機本來就代表逃出去了,一樣拒絕。
        return None
    return target


def sha256(path):
    """整個檔案的 SHA-256。

    1 MB 一塊串流讀,不是整包讀進記憶體:模組裡可能有 models.big 這種
    536 MB 的大檔,整包讀進去會直接把記憶體吃光。
    """
    h = hashlib.sha256()
    with open(path, 'rb') as f:
        for chunk in iter(lambda: f.read(1 << 20), b''):
            h.update(chunk)
    return h.hexdigest()


def human(n):
    """位元組數轉成人看得懂的寫法。B 不帶小數,KB 以上一位小數,單位到 GB 為止。"""
    for unit in ('B', 'KB', 'MB', 'GB'):
        if n < 1024 or unit == 'GB':
            return '%.1f %s' % (n, unit) if unit != 'B' else '%d B' % n
        n /= 1024.0


# ── 原子寫入:所有會動到磁碟的地方都走這裡 ──────────────────
# 為什麼不用「目的檔 + '.part'」這種名字(2026-09-05 第二輪修掉的):
# 那個名字**猜得到**。先在旁邊擺一個叫 '<目的檔>.part' 的符號連結指到別處,
# 腳本自己 open(part, 'wb') 的那一刻就會跟著連結,把外面那個檔截成 0,
# 而 os.replace 只換掉連結本身 —— 等腳本發現不對,外面的檔已經被清掉了。
# Path.exists() 擋不住這一招:指向不存在目標的連結在 exists() 眼裡是 False。
# 改用 tempfile.mkstemp(dir=同一個資料夾):名字是隨機的,而且它內部用
# O_CREAT|O_EXCL 開檔,已經存在(包含已經是連結)就直接失敗,不會跟著連結走。


def refuse_if_link(path, what):
    """path 是符號連結就中止。目的檔與備份檔在動它之前都要問一次。

    ⚠️ 這裡一定要用 os.path.islink 而不是 os.path.exists:
       指向不存在目標的連結(dangling symlink)在 exists() 眼裡是 False,
       在 islink() / os.path.lexists() 眼裡才是 True。
    """
    if not os.path.islink(path):
        return
    try:
        where = os.readlink(path)
    except OSError:
        where = '(讀不出來)'
    raise Stop('%s 是一個符號連結(%s),不敢動它。\n'
               '  它指向:%s\n'
               '  寫下去改到的是那一個檔,不是這裡的檔。\n'
               '  自己確認那是不是你要的,確認完把連結刪掉再跑一次。'
               % (path, what, where))


def _mktemp_beside(dst, tag):
    """在 dst 隔壁開一個名字猜不到的暫存檔,回傳 (fd, 路徑)。

    一定要跟 dst 同一個資料夾:os.replace 只有在同一個檔案系統上才是原子的,
    暫存檔放 /tmp 再 replace 回來,跨磁碟時會退化成「複製 + 刪除」。
    名字前面加一個點,是為了讓它在檔案總管裡不顯眼;結尾的隨機碼由 mkstemp 給。
    留下來的殘骸長成 '.attrib.dat.install-8p3k2q1x',
    不以任何一種備份副檔名結尾,所以 --scan 與 --restore 都不會把它當成備份。
    """
    d = os.path.dirname(dst) or '.'
    base = os.path.basename(dst)[:80]        # 檔名長度上限 255,留餘裕給隨機碼
    return tempfile.mkstemp(dir=d, prefix='.%s.%s-' % (base, tag))


def _drop_tmp(tmp):
    """收拾暫存檔。它自己失敗不可以蓋掉真正的錯誤,所以吞掉例外。"""
    try:
        os.remove(tmp)
    except OSError:
        pass


def _match_mode(tmp, dst, src_for_stat=None):
    """把暫存檔的權限(必要時還有時間戳)調成該有的樣子。換名之前做。"""
    if src_for_stat is not None and os.path.isfile(src_for_stat):
        try:
            shutil.copystat(src_for_stat, tmp)     # 保住來源的時間戳
        except OSError:
            pass
    if os.path.isfile(dst):
        shutil.copymode(dst, tmp)                  # 目的檔原本的權限優先
    else:
        os.chmod(tmp, 0o644)                       # mkstemp 給的是 0600,太緊


def _put_in_place(tmp, dst, src_for_stat=None, record=False):
    """暫存檔就位:對權限,再在不可中斷段裡換名並登記。共用收尾。

    ⚠️ 順序是「換完才記」,而且兩件事包在同一段不可中斷的程式裡(_commit)。
       2026-09-05 第二輪是「先記,再換」,理由是怕訊號落在 replace 與登記之間
       而漏報 —— 但那樣就換成了另一種說謊:訊號落在登記與 replace 之間時,
       檔案一個位元組都沒變,收尾卻說「已經有 1 個遊戲檔被換掉」
       (2026-09-06 實測到的)。兩個縫隙都不必忍:包成不可中斷段就都沒了。
    """
    _match_mode(tmp, dst, src_for_stat)
    _commit(tmp, dst, record=record)


def _prepare_bytes(dst, data, tag='tmp'):
    """把 data 寫成 dst 隔壁的暫存檔:fsync、讀回來比對 SHA-256、對好權限,
    回傳暫存檔的路徑。**只準備,不換名。**

    換名交給 _swap / _commit,分開的用意是讓多檔安裝做得到「要嘛全裝、
    要嘛全不裝」:先把每一個檔的暫存檔都備妥(這一整段一個遊戲檔都沒動),
    全部備妥了才一次換完。磁碟寫滿、權限中途被改掉這一類失敗,
    結果就是「全部沒裝」而不是「裝了一半」。
    """
    refuse_if_link(dst, '要寫入的目的檔')
    want = hashlib.sha256(data).hexdigest()
    fd, tmp = _mktemp_beside(dst, tag)
    try:
        with os.fdopen(fd, 'wb') as fh:
            fh.write(data)
            fh.flush()
            os.fsync(fh.fileno())
        if sha256(tmp) != want:
            raise Stop('%s 寫出去之後讀回來對不上 —— 磁碟或檔案系統有問題,'
                       '原本的檔一個位元組都沒動。' % dst)
        _match_mode(tmp, dst)
        return tmp
    except BaseException:
        _drop_tmp(tmp)
        raise


def _drop_staged(staged, done):
    """把還沒換上去的暫存檔收乾淨(已經換掉的那幾個 tmp 早就不在了)。"""
    for item in staged[done:]:
        _drop_tmp(item[0])


def atomic_write_bytes(dst, data, tag='tmp', record=False):
    """把 data 原子地寫成 dst。任何一步失敗,dst 一個位元組都不會變。

    順序是:開唯一暫存檔 → 寫 → fsync → **讀回來比對 SHA-256** → 換上去。
    複驗排在 os.replace **之前**,所以驗不過的時候正本還是原來那一份,
    不需要「發現不對再補救」。驗不過就丟例外,絕不會安靜地回 0。
    換名與登記包在同一段不可中斷的程式裡(見 _NoInterrupt),
    Ctrl-C 不會落在那兩行中間。
    """
    tmp = _prepare_bytes(dst, data, tag=tag)
    try:
        _commit(tmp, dst, record=record)
    except BaseException:
        _drop_tmp(tmp)
        raise


def atomic_copy_over(dst, src, tag='tmp', want_digest=None, record=False):
    """把 src 原子地換到 dst 上(備份、還原、回滾都走這裡)。

    一樣是先寫暫存檔、讀回來逐位元組比對(比的是 SHA-256)、通過才換上去;
    換名與登記一樣包在不可中斷段裡(見 _NoInterrupt)。
    want_digest 是呼叫的人已經算過的來源雜湊,省一次整檔重讀;沒給就當場算。
    ⚠️ 2026-09-05 之前 --restore 是 shutil.copy2(備份, 遊戲檔) 直接蓋,
       copy2 開檔的第一件事就是把遊戲檔截成 0 —— 檔頭安全網第 5 條有量到的數字。
    """
    refuse_if_link(dst, '要蓋回去的目的檔')
    refuse_if_link(src, '來源檔')
    want = want_digest or sha256(src)
    fd, tmp = _mktemp_beside(dst, tag)
    try:
        with os.fdopen(fd, 'wb') as fh:
            with open(src, 'rb') as sf:
                shutil.copyfileobj(sf, fh, 1 << 20)
            fh.flush()
            os.fsync(fh.fileno())
        if sha256(tmp) != want:
            raise Stop('%s 複製過去之後讀回來跟來源對不上,不敢換上去 —— '
                       '目的地那個檔一個位元組都沒動。' % dst)
        _put_in_place(tmp, dst, src_for_stat=src, record=record)
    except BaseException:
        _drop_tmp(tmp)
        raise


# ── 找出你改過什麼 ──────────────────────────────────────────
def find_changed(game_root):
    """哪些檔旁邊有本站工具留下的備份 = 你改過哪些檔。

    這是整支工具的地基:不跟原版比對、也不要你自己記得改過什麼,
    只認「同一個資料夾裡,某個檔與它的備份同時存在」。
    回傳每一筆都帶著 same 欄位,標記它跟備份是不是逐位元組相同。
    ⚠️ 同一個檔旁邊可能同時有好幾份備份(舊的通用 .bak 加上新的專屬 .stancebak,
       或兩課各留一份)。2026-10-30 之前只拿檔名排在最前面的那一份來比,
       結果取決於檔名字典順序、不是備份可不可信。現在先按「被備份的那個檔」把
       它所有的備份聚在一起,再一起判斷:只有現在的檔跟**每一份**備份都逐位元組
       相同才算 same(等於沒改);只要跟任何一份不同,就保守地收進包。
       baks 欄位記著跟每一份比的結果,--scan 靠它講清楚是跟哪一份相同、哪一份不同。
       bak 欄位只是要印的那一個名字:專屬副檔名優先於通用的 .bak,其餘照副檔名排,
       跟檔名字典順序、BAK_SUFFIXES 的排列順序都無關。
    """
    out = []
    root = os.path.realpath(game_root)
    for dirpath, dirnames, filenames in os.walk(root):
        # 必須「就地」改 dirnames 才會影響 os.walk 接下來要走的路,
        # 指派一個新的 list 給它是沒有用的。
        # 跳過點開頭的資料夾:.git、macOS 的 .Trashes 那些不是遊戲檔。
        dirnames[:] = [d for d in dirnames if not d.startswith('.')]
        names = set(filenames)
        groups = {}    # 被備份的檔名 -> [(備份副檔名, 備份檔名)]:一個檔只收一筆,
        #                不然 --scan 印兩行、--build 對同一個 arcname 寫兩次
        for fn in filenames:
            # 一個檔名只算一種備份副檔名:配到就處理完然後 break,不再往下試。
            for suf in BAK_SUFFIXES:
                if not fn.endswith(suf):
                    continue
                orig = fn[:-len(suf)]
                # 「備份」與「被備份的那個檔」要同時在,才算你改過。
                # 只剩備份代表本尊被刪了,那沒有東西可以打包。
                if orig in names:
                    groups.setdefault(orig, []).append((suf, fn))
                break
        for orig, baks in groups.items():
            baks.sort(key=lambda sf: (sf[0] == '.bak', sf[0]))   # 專屬在前、通用 .bak 在後
            p = os.path.join(dirpath, orig)
            cur = sha256(p)
            # 逐位元組比對現在的檔與每一份備份:全部一樣才代表你改了又改回去,
            # 打包時才會自動略過,不必讓別人多蓋一次一模一樣的檔。
            cmp = [(suf, sha256(os.path.join(dirpath, fn)) == cur) for suf, fn in baks]
            out.append({
                'rel': os.path.relpath(p, root).replace(os.sep, '/'),
                'size': os.path.getsize(p),
                'bak': cmp[0][0],
                'baks': cmp,
                'same': all(ok for _suf, ok in cmp),
            })
    return sorted(out, key=lambda x: x['rel'])


def _bak_note(it):
    """同一個檔旁邊有兩份以上備份時,講清楚它跟哪一份相同、跟哪一份不同。

    只有一份備份(或手動指定的,沒有 baks 欄位)就回空字串。
    """
    baks = it.get('baks') or []
    if len(baks) < 2:
        return ''
    ok = '、'.join(s for s, same in baks if same)
    bad = '、'.join(s for s, same in baks if not same)
    if not bad:
        return '旁邊有 %d 份備份,跟每一份都一樣' % len(baks)
    if not ok:
        return '旁邊有 %d 份備份,跟每一份都不一樣(%s)' % (len(baks), bad)
    return ('旁邊有 %d 份備份:跟 %s 一樣、跟 %s 不一樣 —— 只要跟任何一份不同,'
            '就保守地收進包' % (len(baks), ok, bad))


def cmd_scan(game_root):
    """--scan:把「你改過什麼」列出來。整支不寫任何檔。

    這一步的價值不在功能,在於**讓你在打包之前先看一眼**:
    清單裡有沒有你不預期的東西,是判斷這一包該不該送出去的唯一機會。
    """
    items = find_changed(game_root)
    if not items:
        print('  這份遊戲裡找不到任何本站工具留下的備份。')
        print('  也就是說:你還沒有用本站的工具改過東西,或者你已經全部還原了。')
        return 0
    print('  你改過這些檔(依據是旁邊有本站工具留下的備份):')
    print()
    total = 0
    for it in items:
        mark = '  ⚠️ 跟備份一樣(等於沒改)' if it['same'] else ''
        print('    %-46s %10s  %s%s'
              % (clean_text(it['rel'], 160), human(it['size']),
                 clean_text(it['bak'], 40), mark))
        note = _bak_note(it)
        if note:
            print('      ↳ %s' % clean_text(note, 200))
        total += it['size']
    print()
    print('  共 %d 個檔,合計 %s。' % (len(items), human(total)))
    same = [i for i in items if i['same']]
    if same:
        print('  ⚠️ 其中 %d 個跟備份逐位元組相同 —— 打包時會自動略過。' % len(same))
    print()
    print('  ⚠️ 這只看得到**本站工具**留下的備份。你用別的工具改的、手動覆蓋的,')
    print('     它看不到 —— 那些要用 --add 自己指定。')
    return 0


# ── 打包 ────────────────────────────────────────────────────
def cmd_build(game_root, out, name, author, version, note, extra):
    """--build:把改過的檔收成一個 .mvpmod(本質就是一個 ZIP)。

    收的東西 = 掃到的改動(跳過與備份逐位元組相同的)加上 --add 明確指定的。
    manifest.json 記下每個檔的相對路徑、大小與 SHA-256;
    打完會立刻重新開起來驗一次雜湊,對不上就停下來報錯。
    ✅ 2026-09-05 第二輪起,那一驗排在 os.replace **之前**:驗的是還沒改名的
       暫存檔,沒過就把暫存檔刪掉,你指定的位置**不會生出任何東西**。
       (之前是先改名再驗,驗不過會在那個位置留下一個完整、打得開、
        卻跟 manifest 對不上的包,要玩家自己記得刪。)

    整段只讀遊戲資料夾、只寫你指定的那個 .mvpmod,不會動到遊戲的任何一個檔 ——
    但那是**擋出來的**,不是天生的,見下面第一段。
    """
    root = os.path.realpath(game_root)
    # 輸出路徑是你自己打的,腳本不會替你判斷那是不是一個重要的檔。
    # 2026-09-05 之前這裡沒有任何檢查:把 --build 指到一個已經存在的遊戲檔,
    # 那個檔會直接被寫成一個 ZIP,沒有備份、沒有警告,最後還印一個 ✅
    # (實測拿一份 840,643 bytes 的 attrib.dat 複本,跑完變成 154,336 bytes 的 ZIP)。
    # 所以現在只有兩種情形准蓋過去:那個位置本來就沒有東西,
    # 或者它是一個副檔名 .mvpmod、而且不在遊戲資料夾裡面的檔(重打同一包的正常情形)。
    refuse_if_link(out, '--build 的輸出位置')
    out_abs = os.path.realpath(out)
    out_dir = os.path.dirname(out_abs) or '.'
    if not os.path.isdir(out_dir):
        raise Stop('%s 的上一層資料夾不存在,寫不進去。' % out)
    if os.path.exists(out_abs):
        try:
            inside = os.path.commonpath([root, out_abs]) == root
        except ValueError:
            inside = False        # 不同磁碟機,當然不在遊戲資料夾裡面
        if inside:
            raise Stop('%s 已經存在,而且就在遊戲資料夾裡面。\n'
                       '  寫下去會直接蓋掉它,而且不會留備份。換一個位置。' % out)
        if not out_abs.lower().endswith('.mvpmod'):
            raise Stop('%s 已經存在,而且副檔名不是 .mvpmod。\n'
                       '  怕蓋掉你重要的檔,不寫。換一個檔名。' % out)
    # 改了又改回去的檔不收:收進去只會讓別人多蓋一次一模一樣的檔。
    items = [i for i in find_changed(root) if not i['same']]
    # --add 是給「本站工具看不到的改動」用的逃生口(別的工具改的、手動覆蓋的)。
    # 一樣要過 safe_target(),而且檔案要真的在,免得把一個打不開的路徑寫進 manifest。
    for rel in extra:
        rel = rel.replace(os.sep, '/')
        t = safe_target(root, rel)
        if not t or not os.path.isfile(t):
            raise Stop('--add 指定的 %s 不在遊戲資料夾裡,或者不存在。' % rel)
        if rel not in [i['rel'] for i in items]:
            items.append({'rel': rel, 'size': os.path.getsize(t), 'bak': '(手動指定)', 'same': False})
    if not items:
        raise Stop('沒有東西可以打包。\n'
                   '  先跑 --scan 看看你改過什麼;如果是用別的工具改的,用 --add 指定。')

    files = []
    for it in sorted(items, key=lambda x: x['rel']):
        p = safe_target(root, it['rel'])
        if not p:
            raise Stop('這個路徑不安全,不敢打包:%s' % it['rel'])
        files.append({'path': it['rel'], 'size': it['size'], 'sha256': sha256(p)})

    manifest = {
        'format': 'mvpmod/1',
        'name': name,
        'author': author,
        'version': version,
        'note': note or '',
        'made_by': 'mvp_pack.py · toni的MVP模組補習班',
        'files': files,
    }
    # 全部寫進一個名字猜不到的暫存檔,**驗過了**才換到你指定的位置。
    # 暫存檔的名字由 mkstemp 給(見 _mktemp_beside 的說明),不是 out + '.part' ——
    # 那個名字猜得到,先擺一個同名的符號連結在那裡就能騙腳本去寫別的檔。
    fd, tmp = _mktemp_beside(out_abs, 'build')
    try:
        with os.fdopen(fd, 'wb') as fh:
            with zipfile.ZipFile(fh, 'w', zipfile.ZIP_DEFLATED) as z:
                z.writestr(MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=1))
                for f in files:
                    z.write(safe_target(root, f['path']), 'files/' + f['path'])
            fh.flush()
            os.fsync(fh.fileno())
        # 讀回來驗。「讀回來」是從磁碟重新開檔,不是拿記憶體裡的 bytes 再算一次雜湊:
        # 拿記憶體那份驗等於自己驗自己,壓縮或寫檔那一步壞掉也照樣印成功。
        with zipfile.ZipFile(tmp) as z:
            m = json.loads(z.read(MANIFEST).decode('utf-8'))
            for f in m['files']:
                if hashlib.sha256(z.read('files/' + f['path'])).hexdigest() != f['sha256']:
                    raise Stop('包裡的 %s 跟原檔對不上,這一包不能用。\n'
                               '  沒有寫出任何檔案 —— %s 那個位置維持原狀。'
                               % (f['path'], out))
        size = os.path.getsize(tmp)
        _put_in_place(tmp, out_abs)
    except BaseException:
        _drop_tmp(tmp)
        raise
    print('  ✅ 打好了:%s(%s)' % (out, human(size)))
    print('     %d 個檔,每一個的 SHA-256 都寫進 manifest 並當場驗過。' % len(files))
    print('  ⚠️ 這一包裡面是**被你改過的 EA 遊戲檔案**。')
    print('     要不要給別人是你自己的判斷,本站不提供散布管道。')
    return 0


# ── 備份的完整性 ────────────────────────────────────────────
# 這一整段只服務一件事:--restore 拿一份備份蓋回遊戲檔之前,先問「這份備份完整嗎」。
# 2026-09-05 之前只問「是不是 0 bytes」,所以一份被截成 60% 的備份會被照樣蓋回去,
# 而且收尾還印 ✅(實測:840,643 bytes 的 attrib.dat 被換成 504,385 bytes,零警告)。
#
# 分兩層:
#   第一層  收據。--install --apply 留備份的同時,在旁邊寫一個
#           「同名 + .packbak.json」,記下那份備份的大小與 SHA-256。
#           有收據就逐位元組驗得出來,任何形式的半截與竄改都擋得住。
#   第二層  格式。舊版腳本留下的備份沒有收據,只能看檔案本身的格式自打嘴巴
#           (見 truncation_evidence)。驗不出來的照樣蓋回去,但會明講「沒驗到」。
#
# ⚠️ 這裡刻意不用「備份不得小於現在那個檔的一半」那種地板。本站別的工具可以用,
#    因為它們是就地改幾個位元組,備份跟現在的檔大小本來就差不多。這一支不行:
#    它裝的是別人給的檔,新檔可以比原檔大好幾倍也可以小好幾倍,
#    拿現在的檔當尺會把正常的還原也擋掉。


def write_receipt(path, size, digest):
    """寫一張備份收據(暫存檔 → 驗 → 改名,自己也不留半截)。

    走 atomic_write_bytes 是為了跟別的寫入用同一套規矩:名字猜不到的暫存檔、
    fsync、讀回來比對、才換上去。2026-09-05 之前是 path + '.part',
    那個名字先被人擺一個符號連結就會寫到別的地方去。
    """
    refuse_if_link(path, '備份收據')
    blob = json.dumps({'size': size, 'sha256': digest, 'made_by': 'mvp_pack.py'})
    atomic_write_bytes(path, blob.encode('utf-8'), tag='receipt')


def read_receipt(path):
    """讀一張備份收據。回傳 (大小, SHA-256);讀不到就回 None。

    讀不到、壞掉、形狀不對,一律當成「沒有收據」而不是「檢查失敗」——
    收據是 2026-09-05 才加的額外保障,舊版腳本留下的備份本來就沒有,
    把「沒有」當成「壞了」會讓那些人再也還原不了。
    """
    try:
        with open(path, encoding='utf-8') as f:
            d = json.load(f)
        size, digest = d['size'], d['sha256']
    except (OSError, ValueError, KeyError, TypeError):
        return None
    if not isinstance(size, int) or size < 0:
        return None
    if not isinstance(digest, str) or len(digest) != 64:
        return None
    return size, digest


def _looks_texty(head):
    """這一塊位元組看起來像純文字嗎(給 truncation_evidence 用的粗篩)。"""
    if b'\x00' in head:
        return False
    try:
        head.decode('utf-8')
    except UnicodeDecodeError:
        # 4 KB 的切點可能剛好切在一個多位元組字的中間,那不算「不是文字」。
        try:
            head[:-3].decode('utf-8')
        except UnicodeDecodeError:
            return False
    return True


def _pe_min_size(head):
    """MZ/PE 檔頭算出「這個檔至少該有多大」。看不懂就回 None。"""
    if len(head) < 0x40 or head[:2] != b'MZ':
        return None
    lf = int.from_bytes(head[0x3c:0x40], 'little')
    if lf + 24 > len(head) or head[lf:lf + 4] != b'PE\x00\x00':
        return None
    nsec = int.from_bytes(head[lf + 6:lf + 8], 'little')
    opt = int.from_bytes(head[lf + 20:lf + 22], 'little')
    tab = lf + 24 + opt
    if nsec == 0 or tab + nsec * 40 > len(head):
        return None
    end = 0
    for i in range(nsec):
        e = tab + i * 40
        raw_size = int.from_bytes(head[e + 16:e + 20], 'little')
        raw_ptr = int.from_bytes(head[e + 20:e + 24], 'little')
        if raw_ptr:
            end = max(end, raw_ptr + raw_size)
    return end or None


def truncation_evidence(bak, orig):
    """只找「這一份備份明顯是半截的」的**正面證據**。找不到就回 None。

    ⚠️ None 代表「沒驗到」,不代表「驗過了」。兩件事不可以混為一談,
       呼叫的人(cmd_restore)也是分開講的。

    三種格式驗得出來,三種的門檻都在這台機器上實際量過:

      BIGF  檔頭第 5-8 個位元組寫著整包該有多大。⚠️ 這個數字有的檔存成小端、
            有的存成大端:本機 383 個 BIGF 開頭的 .big 裡,379 個小端、4 個大端
            (models.big / portrait.big / pnamedat.big / pnamehdr.big);
            剛安裝好的原版那一份 207 個全部對得上其中一種。
            所以兩種都算,只有「比兩種都小」才叫半截。比兩種都大**不算** ——
            那是社群模組往檔尾接資料留下的孤兒區塊,本站早就量過。
      MZ/PE 最後一個區段的 PointerToRawData + SizeOfRawData 就是檔案至少該有多大。
            本機 18 個 exe / dll 全部通過,包含後面接了安裝資料的 TiT.exe
            (5,221,437 bytes,而區段只算到 233,984)。
      純文字 原檔以換行結尾而備份沒有,就是抄到一半停了。本機抽驗 20 個純文字
            遊戲檔(含 attrib.dat)全部以換行結尾。
            ⚠️ 這一條有誤判的可能:哪天遇到一個本來就不以換行結尾的文字檔,
               完整的備份也會被擋。擋下來不會弄壞任何東西 —— 備份還在原地,
               確定它是完整的就自己複製回去。
    """
    size = os.path.getsize(bak)
    with open(bak, 'rb') as f:
        head = f.read(16384)

    if head[:4] in (b'BIGF', b'BIGH', b'BIG4') and len(head) >= 8:
        le = int.from_bytes(head[4:8], 'little')
        be = int.from_bytes(head[4:8], 'big')
        want = min(le, be)
        if want and size < want:
            return ('的 %s 檔頭說整包至少要有 %d bytes,它只有 %d —— 是半截的。'
                    % (head[:4].decode('ascii'), want, size))
        return None

    least = _pe_min_size(head)
    if least is not None:
        if size < least:
            return ('是一個執行檔,區段表說它至少要有 %d bytes,它只有 %d —— 是半截的。'
                    % (least, size))
        return None

    # 純文字:要兩邊都看得到才算數,所以原檔不在(被刪了)就不驗這一條。
    if not os.path.isfile(orig) or os.path.getsize(orig) == 0:
        return None
    with open(orig, 'rb') as f:
        ohead = f.read(16384)
        f.seek(-1, os.SEEK_END)
        olast = f.read(1)
    if _looks_texty(head) and _looks_texty(ohead):
        with open(bak, 'rb') as f:
            f.seek(-1, os.SEEK_END)
            blast = f.read(1)
        if olast in (b'\n', b'\r') and blast not in (b'\n', b'\r'):
            return '是純文字,而它沒有像原檔那樣以換行結尾 —— 抄到一半停了。'
    return None


def check_backup(bak, orig):
    """這一份備份能不能拿來蓋回去。回傳 (判定, 一句話)。

    判定有三種:
      'ok'          驗過了,完整。
      'unverified'  沒有收據,只驗得出「不是 0 bytes、也看不出明顯的半截」。
                    ⚠️ 這不等於「驗過了」:truncation_evidence 找不到證據,
                       有可能是它真的完整,也有可能是這個格式驗不出來。
      'refuse'      驗出問題,不可以拿來蓋。
    """
    if os.path.islink(bak):
        # 備份檔本身是連結:讀到的是別的地方的內容,而且那個地方可能在遊戲資料夾外。
        return 'refuse', '是一個符號連結,不是真的備份 —— 不敢照著它蓋回去。'
    if os.path.islink(orig):
        # 遊戲檔本身是連結:蓋回去只會把連結換成普通檔,連結指向的那一份反而還在。
        return 'refuse', '對應的遊戲檔是一個符號連結,情況太特別,交給你自己處理。'
    if not os.path.isfile(bak):
        return 'refuse', '不見了。'
    size = os.path.getsize(bak)
    if size == 0:
        # 0 bytes 的備份多半是複製到一半被中斷的。
        # 拿它蓋回去等於直接把遊戲檔清空。
        return 'refuse', '是 0 bytes —— 複製到一半被中斷的多半長這樣。'
    receipt = read_receipt(orig + BAK_RECEIPT)
    if receipt is not None:
        want_size, want_digest = receipt
        if size != want_size:
            return 'refuse', ('的收據說它應該有 %d bytes,實際只有 %d —— 是半截的。'
                              % (want_size, size))
        if sha256(bak) != want_digest:
            return 'refuse', '的內容跟收據上的 SHA-256 對不上 —— 它被改過或壞了。'
        return 'ok', ''
    why = truncation_evidence(bak, orig)
    if why:
        return 'refuse', why
    return 'unverified', '沒有收據(舊版腳本留下的),只驗得出「不是 0 bytes、看不出明顯的半截」。'


# ── 預覽 / 安裝 / 還原 ──────────────────────────────────────
# ── 壓縮檔的大小上限(2026-10-30 加)────────────────────────
# 壓縮炸彈:幾十 KB 的 .mvpmod 解開是幾 GB。以前這裡一個上限都沒有,
# --install --apply 會把每個項目整個讀進記憶體(實測 300 MB 的零,最大常駐記憶體 341 MB),
# 預覽顯示的大小又是 manifest 自己寫的,讀的人完全看不出來。
# 下面這些數字全部來自壓縮檔**目錄**上的 file_size / compress_size,檢查的時候一個項目的
# 內容都還沒有讀。這個檢查擋得住記憶體爆掉,是因為 python 的 zipfile 讀一個項目時,
# 最多只會讀到目錄上宣告的 file_size(多的丟掉、再由 CRC 驗出不對),
# 所以「宣告的數字不超過上限」就等於「讀進記憶體的不會超過上限」。
MAX_ENTRY_BYTES = 1 << 30      # 單一項目解開後最多 1 GB
MAX_TOTAL_BYTES = 4 << 30      # 整包解開後最多 4 GB
MAX_RATIO = 200                # 解開 / 壓縮 的比值最多 200 倍
MAX_MANIFEST_BYTES = 1 << 20   # manifest.json 最多 1 MB

# 讀壓縮檔內容時可能丟出來的例外(加密、壓縮方式不支援、資料壞掉、讀檔失敗)。
# 全部換成一句人話,不讓 Python 的原始 traceback 噴到玩家臉上。
_ZIP_READ_ERRORS = (zipfile.BadZipFile, zipfile.LargeZipFile, RuntimeError,
                    NotImplementedError, zlib.error, EOFError, OSError)


def zip_limit_problems(infolist):
    """只看壓縮檔目錄上宣告的數字,回傳 [(項目名或 None, 說明)]。一個項目的內容都不讀。

    項目名是 None 的是「整包」層級的問題。空清單代表全部在上限以內。
    """
    problems = []
    total = 0
    for zi in infolist:
        total += zi.file_size
        if zi.file_size > MAX_ENTRY_BYTES:
            problems.append((zi.filename, '單項解開後有 %s,超過上限 %s'
                             % (human(zi.file_size), human(MAX_ENTRY_BYTES))))
        if zi.file_size > MAX_RATIO * max(zi.compress_size, 1):
            problems.append((zi.filename, '壓縮比 %d 倍(解開 %s、壓縮後只有 %s),超過上限 %d 倍'
                             % (zi.file_size // max(zi.compress_size, 1),
                                human(zi.file_size), human(zi.compress_size), MAX_RATIO)))
    if total > MAX_TOTAL_BYTES:
        problems.append((None, '整包解開後合計 %s,超過上限 %s'
                         % (human(total), human(MAX_TOTAL_BYTES))))
    return problems


class _OpenOnce(object):
    """整個預覽 / 安裝只開這一次 .mvpmod,之後每一步都用同一個開啟中的壓縮檔。

    用法:`with _OpenOnce(mod) as z:`,z 是 zipfile.ZipFile。

    ⚠️ 2026-10-30 稽核(複查,檢查後替換):以前 --preview 開一次讀 manifest 與目錄、
       對那次的目錄查過大小上限,又**依路徑重新開一次**去解開項目(認 MZ、算 SHA-256);
       --install 在預覽之外還另外開兩次。每開一次都重新看一次路徑現在指的是誰 ——
       兩次之間這個檔被換掉,第二次開到的包沒有人再查過大小、總量與壓縮比
       (第一次是小而合法的包,換上去的是壓縮炸彈,預覽會把整個項目解壓),
       預覽清單上的大小也還是第一次留下來的。
       現在只開一次:目錄、大小上限、預覽、雜湊、讀內容用的是同一個檔案身分
       (開著的檔案描述元),路徑之後被換成什麼都不影響這一輪。
       壓縮檔的目錄只在開檔那一刻讀一次,之後每個項目讀回來還要過 CRC 與目錄宣告的大小。
    每一種開不起來的狀況都換成一句人話(Stop),跟 read_manifest 以前的措辭一樣。
    """

    def __init__(self, mod):
        self.mod = mod
        self.fh = None
        self.z = None

    def _close(self):
        z, fh = self.z, self.fh
        self.z = self.fh = None
        try:
            if z is not None:
                z.close()
        finally:
            if fh is not None:
                fh.close()

    def __enter__(self):
        mod = self.mod
        if not os.path.isfile(mod):
            raise Stop('找不到 %s。' % clean_text(mod, 300))
        try:
            self.fh = open(mod, 'rb')
            self.z = zipfile.ZipFile(self.fh)
        except zipfile.BadZipFile:
            self._close()
            raise Stop('%s 不是一個壓縮檔。' % clean_text(mod, 300))
        except _ZIP_READ_ERRORS as e:
            self._close()
            raise Stop('%s 讀不下去(加密、壓縮方式不支援,或是檔案壞了):%s'
                       % (clean_text(mod, 300), clean_text(e, 200)))
        except BaseException:
            self._close()
            raise
        return self.z

    def __exit__(self, *_exc):
        self._close()
        return False


def read_manifest(mod, z=None):
    """打開 .mvpmod 讀出 manifest.json,回傳 (manifest, 項目表, 大小問題)。

    項目表是 {壓縮檔裡的名字: ZipInfo}:--preview 靠它確認 manifest 說有的檔真的在包裡
    (manifest 說有、ZIP 裡卻找不到,代表這一包壞掉或被人動過,要在裝之前就看出來),
    也靠它拿到壓縮檔裡**實際**的大小。大小問題見 zip_limit_problems()。

    z 是呼叫的人已經開好的壓縮檔(見 _OpenOnce):給了就用它讀,**不再依路徑開第二次**;
    沒給就自己開一次(整個函式只開這一次)。

    每一種讀不下去的狀況都換成一句人話(Stop),不讓 Python 的原始例外噴到玩家臉上。
    ⚠️ 這裡唯一讀內容的地方是 manifest.json,而且讀之前先確認它自己不超過 1 MB;
       其餘項目的內容,大小問題沒清空之前誰都不准讀(見 cmd_preview / cmd_install)。
       大小問題**不在這裡丟例外**,而是交給呼叫的人:--preview 要能把有問題的包
       列給玩家看(標 🔴),--install 看到問題就整包拒絕。
    """
    if z is None:
        with _OpenOnce(mod) as z1:
            return read_manifest(mod, z=z1)
    try:
        infolist = z.infolist()
        infos = {}
        for zi in infolist:
            infos[zi.filename] = zi        # 同名的以最後一個為準,跟 z.read() 一致
        if MANIFEST not in infos:
            raise Stop('%s 裡面沒有 manifest.json,這不是一個 .mvpmod 包。'
                       % clean_text(mod, 300))
        mzi = infos[MANIFEST]
        if mzi.file_size > MAX_MANIFEST_BYTES:
            raise Stop('manifest.json 解開後有 %s,超過上限 %s,不讀。'
                       '正常的包不會有這麼大,這一包壞了,或是被人動過。'
                       % (human(mzi.file_size), human(MAX_MANIFEST_BYTES)))
        problems = zip_limit_problems(infolist)
        m = json.loads(z.read(mzi).decode('utf-8'))
    except zipfile.BadZipFile:
        raise Stop('%s 不是一個壓縮檔。' % clean_text(mod, 300))
    except (UnicodeDecodeError, ValueError):
        # json.JSONDecodeError 是 ValueError 的子類,兩種一起接:
        # 下載途中被截斷、或被人手動改壞的 manifest 會走到這裡。
        # 2026-09-05 之前這兩種都是 Python 的原始 traceback 噴到玩家臉上。
        raise Stop('%s 的 manifest.json 讀不下去,這一包壞了,或是被人動過。'
                   % clean_text(mod, 300))
    except _ZIP_READ_ERRORS as e:
        raise Stop('%s 讀不下去(加密、壓縮方式不支援,或是檔案壞了):%s'
                   % (clean_text(mod, 300), clean_text(e, 200)))
    if not isinstance(m, dict):
        raise Stop('manifest 壞了:最外層不是一組設定。')
    if not isinstance(m.get('files'), list):
        raise Stop('manifest 壞了:沒有 files 清單。')
    # 清單裡的每一項都要是一組設定。不是的話,後面 f.get('path') 那一行
    # 會丟 AttributeError,同樣是原始 traceback。
    if not all(isinstance(f, dict) for f in m['files']):
        raise Stop('manifest 壞了:檔案清單裡有不是一組設定的項目。')
    return m, infos, problems


def _peek_head(z, zi):
    """項目的前兩個位元組(認 MZ 用)。只解開開頭,不讀整個項目。"""
    with z.open(zi) as fh:
        return fh.read(2)


def _zip_sha256(z, zi):
    """項目的 SHA-256 與實際讀到的位元組數。1 MB 一塊串流讀,不整個放進記憶體。"""
    h = hashlib.sha256()
    n = 0
    with z.open(zi) as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b''):
            h.update(chunk)
            n += len(chunk)
    return h.hexdigest(), n


def _read_member(z, zi):
    """把一個項目整個讀進記憶體。讀的數量以目錄宣告的 file_size 為上限,
    多一個位元組都不收;實際讀到的跟宣告的不一樣就停下來。呼叫之前要先過 zip_limit_problems()。"""
    with z.open(zi) as fh:
        data = fh.read(zi.file_size + 1)
    if len(data) != zi.file_size:
        raise Stop('包裡的 %s 解開後是 %d bytes,跟壓縮檔宣告的 %d bytes 不一樣,'
                   '這一包壞了,或是被人動過。'
                   % (clean_text(zi.filename, 160), len(data), zi.file_size))
    return data


def _size_flag(f, zi):
    """manifest 寫的 size 跟壓縮檔裡實際的大小對不對得上。對得上或沒寫就回 ''。"""
    if 'size' not in f:
        return ''
    claimed = f['size']
    if not isinstance(claimed, int) or isinstance(claimed, bool) or claimed < 0:
        return '  🔴 manifest 的 size 不是數字(寫的是 %s)' % clean_text(claimed, 40)
    if zi is not None and claimed != zi.file_size:
        return ('  🔴 大小不一致:manifest 寫 %s bytes,包裡實際是 %s bytes'
                % (format(claimed, ','), format(zi.file_size, ',')))
    return ''


def cmd_preview(game_root, mod, allow_code=False, z=None, shas=None):
    """--preview:逐檔列出這一包要放到哪裡,並確認每個位置都在遊戲資料夾裡面。

    z 是呼叫的人已經開好的壓縮檔(見 _OpenOnce),給了就用它,不再依路徑開別的;
    沒給就自己開一次。shas 給一個 dict 的話,預覽算出 SHA-256 的程式碼項目會記進去
    ({manifest 路徑: SHA-256}),--install 讀內容時拿來核對「裝進去的就是畫面上列的那一份」。

    ⚠️ 這裡驗的是**位置**與**像不像程式**,不是內容。一個路徑完全合法的 attrib.dat
       可以把你的名單改成任何樣子。本站沒有掃描包內容的能力,
       所以這一頁只能告訴你「有哪些檔、會放到哪裡、哪幾個會執行」。

    2026-10-30 起多三件事(細節在檔頭安全網第 7、8 條):
      · 大小是壓縮檔裡**實際**的,跟 manifest 寫的不一樣就標 🔴
      · 壓縮檔超過大小上限就標 🔴,而且不讀任何項目的內容
      · 會執行的程式碼另外列成一塊,沒加 allow_code 就當成有問題

    回傳 0 代表這一包可以裝;回傳 1 代表有問題,--install 會直接停在這裡。

    ⚠️ 2026-10-30 起整個預覽只開一次壓縮檔(見 _OpenOnce):manifest、目錄、大小上限、
       認 MZ、SHA-256 讀的都是同一個檔案身分。以前讀內容那一段依路徑重新開一次,
       兩次之間包被換成壓縮炸彈的話,沒有人再查過上限就整個解壓(複查指出的檢查後替換),
       清單上顯示的大小也是第一次開啟留下的。
    """
    if z is None:
        with _OpenOnce(mod) as z1:
            return _preview_on(game_root, mod, allow_code, z1, shas)
    return _preview_on(game_root, mod, allow_code, z, shas)


def _preview_on(game_root, mod, allow_code, z, shas):
    """cmd_preview 的本體:所有東西都從已經開好的 z 讀。"""
    m, infos, problems = read_manifest(mod, z=z)
    # 不可信的欄位(manifest 是別人寫的)一律經過 clean_text 才印。
    print('  包名:%s' % clean_text(m.get('name', '(沒寫)'), 80))
    print('  作者:%s   版本:%s' % (clean_text(m.get('author', '(沒寫)'), 80),
                                  clean_text(m.get('version', '(沒寫)'), 40)))
    if m.get('note'):
        print('  說明:%s' % clean_text(m['note'], 300))
    print()
    over = set(name for name, _why in problems if name is not None)

    # 第一輪:不印東西,先把每個檔的狀況收齊。
    rows = []
    for f in m['files']:
        rel = f.get('path', '')
        is_str = isinstance(rel, str)
        # 兩件事分開查,錯的時候才講得出是哪一種:
        # 路徑不安全 = 這一包想寫到遊戲資料夾外面;包裡找不到 = 這一包本身缺東西。
        safe = is_str and safe_target(game_root, rel) is not None
        zi = infos.get('files/' + rel) if is_str else None
        row = {'rel': rel, 'zi': zi, 'safe': safe, 'flag': '', 'code': None, 'sha': None,
               'known': None}
        if not safe:
            row['flag'] = '  🔴 路徑不安全'
        elif zi is None:
            row['flag'] = '  🔴 包裡找不到這個檔'
        else:
            row['flag'] = _size_flag(f, zi)
            if zi.filename in over:
                row['flag'] += '  🔴 超過大小上限'
        # 會執行的程式碼:檔名先看(不用讀內容),內容開頭兩個位元組之後再看。
        row['code'] = code_reason(rel) if is_str else None
        rows.append(row)
    if not problems:
        # 大小問題清空了,才准讀項目的內容(只讀前兩個位元組認 MZ;命中的才算整個 SHA-256)。
        # 讀的是 read_manifest 查過大小上限的那個 z(同一個開啟中的檔案),
        # 不是依路徑重新開的另一個;rows 裡的 zi 也是那一次目錄上的項目。
        for row in rows:
            if row['zi'] is None or not row['safe']:
                continue
            try:
                zi = z.getinfo(row['zi'].filename)
                head = _peek_head(z, zi)
                row['code'] = code_reason(row['rel'], head)
                if row['code']:
                    row['sha'], real_n = _zip_sha256(z, zi)
                    if real_n != zi.file_size:
                        row['flag'] += ('  🔴 讀出來是 %s bytes,跟宣告的 %s 不一樣'
                                        % (format(real_n, ','), format(zi.file_size, ',')))
                    else:
                        # 已知的 EA 原廠檔(路徑最後一段與整個檔的 SHA-256 都對得上)不算程式碼,
                        # 但 SHA-256 照記進 shas:--install 讀內容時要算出同一個雜湊。
                        row['known'] = known_ea_file(row['rel'], row['sha'])
                        if row['known']:
                            row['code'] = None
                        if shas is not None:
                            shas[row['rel']] = row['sha']
            except _ZIP_READ_ERRORS + (KeyError,) as e:
                row['flag'] += '  🔴 讀不出內容(%s)' % clean_text(e, 80)

    # 第二輪:印出來。
    print('  裡面有 %d 個檔:' % len(m['files']))
    bad = 0
    for row in rows:
        if row['flag']:
            bad += 1
        size = human(row['zi'].file_size) if row['zi'] is not None else '-'
        print('    %-46s %10s%s' % (clean_text(row['rel'], 160), size, row['flag']))
    print()
    if problems:
        print('  🔴 這個壓縮檔超出大小上限,除了 manifest.json 以外,沒有讀任何一個項目的內容:')
        for name, why in problems:
            print('       %s%s' % (clean_text(name, 120) + ':' if name else '', why))
        print('     幾十 KB 的包解開有幾 GB,讀進記憶體會讓電腦卡死,所以不裝。')
        print()
    codes = [r for r in rows if r['code']]
    if codes:
        print('  🔴 會執行的程式碼(%d 個):' % len(codes))
        for r in codes:
            print('       %s  %s' % (clean_text(r['rel'], 160), human(r['zi'].file_size) if r['zi'] is not None else '-'))
            print('         原因:%s' % r['code'])
            print('         SHA-256:%s' % (r['sha'] or '(沒有讀內容,所以算不出來)'))
        if any(code_ext_of(r['rel']) is None for r in codes):
            print('     其中有的副檔名不是程式,只是內容開頭是 MZ。可能是誤判,')
            print('     但這種寧可誤報:你確定它是資料檔,同樣加 --allow-code 放行。')
        print('     換掉 .exe / .dll 等於讓做這一包的人在你的電腦上跑程式。')
        if allow_code:
            print('  ⚠️ 你加了 --allow-code,上面這些會照樣裝進去。')
        else:
            print('  🔴 沒有加 --allow-code,這一包不可以裝。你認識做這一包的人、')
            print('     也核對過上面的 SHA-256,才加 --allow-code。')
        print()
    # 已知的 EA 原廠檔:照樣列出名稱與 SHA-256,讓讀的人看得到是哪個檔被略過、不是安靜地放行。
    known_rows = [r for r in rows if r.get('known')]
    for r in known_rows:
        print('  ⓘ 已知的 EA 原廠檔,略過:%s  SHA-256:%s' % (clean_text(r['rel'], 160), r['sha']))
    if known_rows:
        print()
    if bad or problems:
        print('  🔴 有 %d 個檔有問題%s,這一包不可以裝。'
              % (bad, '、壓縮檔超出大小上限' if problems else ''))
        return 1
    if codes and not allow_code:
        return 1
    print('  ✅ 每一個檔的路徑都在遊戲資料夾裡面,而且包裡都找得到。')
    if codes:
        print('  ⚠️ 但上面有 %d 個會執行的程式碼,是你加 --allow-code 放行的。' % len(codes))
    return 0


def _rollback(journal):
    """把這一輪已經寫進去的檔退回去。回傳 (退回幾個, [退不回去的說明])。

    每一筆的處理只有兩種:
      · 原本就有這個檔  → 照 .packbak 蓋回去(蓋之前一樣要過 check_backup)
      · 原本沒有這個檔  → 直接刪掉,因為它整個是這一輪生出來的
    倒著跑(reversed)是慣例:交易日誌回滾一律照相反順序,
    這樣「後面的動作依賴前面的動作」時才不會踩到自己。

    ⚠️ 退回去的是**原始**的遊戲檔,不是「這一輪開始之前的樣子」。
       那個檔身上本來就有別的模組時,那一份也會一起被退掉 ——
       跟玩家自己跑 --restore 的結果一樣,是一個講得清楚的狀態。
    ⚠️ 這裡不可以丟例外:它自己是在收拾別的例外,再丟一個會蓋掉真正的死因。
       所以每一筆都各自 try,失敗的收進 bad 讓呼叫的人一起講。
    """
    ok, bad = 0, []
    for e in reversed(journal):
        t, bak, existed = e['t'], e['bak'], e['existed']
        try:
            if not existed:
                if os.path.isfile(t) and not os.path.islink(t):
                    os.remove(t)
                ok += 1
                continue
            verdict, why = check_backup(bak, t)
            if verdict == 'refuse':
                bad.append('%s(它的備份%s)' % (os.path.basename(t), why))
                continue
            atomic_copy_over(t, bak, tag='rollback', record=True)
            ok += 1
        except Exception as exc:
            bad.append('%s(%s)' % (os.path.basename(t), exc))
    return ok, bad


def cmd_install(game_root, mod, apply_it, allow_code=False):
    """--install:先跑一次 --preview,通過了才考慮寫。

    寫入分三個階段:
      第一階段  把每個檔讀進記憶體,順便比對 manifest 上的 SHA-256
      第二階段  每一個要寫的位置都先試一次,確認真的寫得進去
      第三階段  兩關都過了才動磁碟
    先讀完才寫,才不會裝到一半發現包是壞的;先試過才寫,
    才不會因為權限或檔案被鎖住而留下一套半新半舊的遊戲。
    ⚠️ 第二階段只是「試」,不是保證。真的寫到一半失敗時(權限中途被改、
       磁碟寫滿),腳本會**自動退回**這一輪已經寫進去的那幾個檔
       (照著 .packbak 一個一個蓋回去,原本不存在的檔就刪掉),
       然後停下來、講清楚退了幾個、結束碼是 1。
       退回去的是**原始**的遊戲檔,跟你自己跑 --restore 的結果一樣 ——
       所以如果那個檔身上早就有別的模組,那一份也會一起退掉。
       退不回去的(例如備份自己壞了)會逐一列出來,那幾個要你自己處理。
       ⚠️ 備妥階段(留備份、寫暫存檔)就失敗的話,一個遊戲檔都還沒換過,
          第一句會是「還沒開始換檔就失敗了」,並且明講「遊戲檔一個位元組
          都沒有動」;真的換到一半才失敗才會說「寫到一半失敗了」。
          這兩句的差別是問 _MUTATED / _INFLIGHT 得來的,不是拿計數器猜的。
       ⚠️ 自動退回擋得住「寫失敗」,擋不住「斷電」:電源在退回的途中斷掉,
          仍然會留下一套半新半舊的遊戲,那時要靠 --restore。

    ⚠️ 雜湊比對是**有條件**的:manifest 上那一項沒有 sha256 欄位就完全不驗,
       照樣寫進遊戲資料夾(見下面 f.get('sha256') 那一行)。
       本腳本 --build 打的包每個檔都帶 sha256;別的工具打的包不一定有寫,
       沒寫的那種包等於只過路徑檢查。

    apply_it 是 False 時,整支到 --preview 為止就結束,一個位元組都不寫。

    ⚠️ 2026-10-30 起整個 --install 只開一次壓縮檔(見 _OpenOnce):預覽、讀 manifest、查上限、
       讀每個項目共用同一個開著的檔案。以前預覽開兩次、這裡又開兩次,每次重新開檔都重新相信
       一次路徑現在指的是誰(複查指出的檢查後替換)。

    ⚠️ 2026-10-30 起,包裡有「會執行的程式碼」(見檔頭安全網第 7 條)而沒有 allow_code
       (也就是 --allow-code)時,整包不裝、結束碼 1、遊戲檔一個位元組都不動。
       這一關在 cmd_preview 擋一次,下面讀內容的時候**自己再擋一次**(不靠預覽
       有沒有跑過):壓縮檔的大小上限在第一個項目被讀進記憶體**之前**檢查;
       檔名的副檔名與內容開頭的 MZ,在第一個遊戲檔被動到**之前**檢查
       (讀完每個項目就看一次,名單上有一個命中,整包都不寫)。
    """
    # 整個 --install(預覽、讀 manifest、查上限、讀每個項目)只開這一次壓縮檔(見 _OpenOnce):
    # 以前預覽開兩次、這裡又開兩次,每次重新開檔都重新相信一次路徑現在指的是誰,
    # 兩次之間包被換掉,後面那幾次開到的東西沒有人檢查過(複查指出的檢查後替換)。
    with _OpenOnce(mod) as z:
        shas = {}      # 預覽替程式碼項目算好、印在畫面上的 SHA-256
        if cmd_preview(game_root, mod, allow_code, z=z, shas=shas) != 0:
            return 1
        m, _infos, _problems = read_manifest(mod, z=z)
        root = os.path.realpath(game_root)
        print()
        if not apply_it:
            print('  這是預覽,一個位元組都沒有寫。確定的話再跑一次,最後加上 --apply。')
            return 0

        try:
            # 讀任何一個項目之前,先用壓縮檔目錄上的數字再檢查一次大小上限
            # (預覽通過不代表這裡不用查:預覽可以被繞過,這一道不靠它)。
            again = zip_limit_problems(z.infolist())
            if again:
                raise Stop('壓縮檔超出大小上限,不讀,一個檔都沒有被改到:%s'
                           % clean_text(again[0][1], 200))
            # 先全部讀進記憶體,全部讀完才開始寫,不會寫一半。
            # 但雜湊是有條件的:下一行 f.get('sha256') 為真才比對,
            # manifest 沒寫 sha256 的項目不驗就收(別的工具打的包可能就是這種)。
            blobs = {}
            for f in m['files']:
                try:
                    zi = z.getinfo('files/' + f['path'])
                except KeyError:
                    raise Stop('包裡找不到 %s,一個檔都沒有被改到。' % clean_text(f['path'], 160))
                data = _read_member(z, zi)
                if not allow_code:
                    # 名字與開頭兩個位元組一起看(code_reason 兩個都管)。
                    # 這一道不靠 cmd_preview 有沒有跑過,也在第一個遊戲檔被動到之前。
                    why = code_reason(f['path'], data[:2])
                    if why and known_ea_file(f['path'], hashlib.sha256(data).hexdigest()):
                        why = None       # 已知的 EA 原廠檔:路徑結尾與整個檔的 SHA-256 都對得上
                    if why:
                        raise Stop('%s 是會執行的程式碼(%s),沒有加 --allow-code,不裝。'
                                   '一個檔都沒有被改到。' % (clean_text(f['path'], 160), why))
                if f.get('sha256') and hashlib.sha256(data).hexdigest() != f['sha256']:
                    raise Stop('包裡的 %s 跟 manifest 寫的 SHA-256 對不上,不敢裝。'
                               % clean_text(f['path'], 160))
                # 預覽替這個程式碼項目列過 SHA-256:讀到的位元組要算出同一個雜湊,
                # 不然「畫面上核對過的」跟「裝進去的」就不是同一份(--allow-code 放行的是前者)。
                if f['path'] in shas and hashlib.sha256(data).hexdigest() != shas[f['path']]:
                    raise Stop('%s 的內容跟預覽畫面上列的 SHA-256 不一樣,不敢裝。'
                               '一個檔都沒有被改到。' % clean_text(f['path'], 160))
                blobs[f['path']] = data
        except _ZIP_READ_ERRORS as e:
            raise Stop('%s 讀不下去,一個檔都沒有被改到:%s'
                       % (clean_text(mod, 300), clean_text(e, 200)))

    # 第二階段:一個位元組都還沒寫之前,先確認每一個位置都寫得進去。
    # 擋的是「遊戲裝在 C:\\Program Files 而沒有用系統管理員身分執行」與
    # 「檔案正被遊戲鎖住」這兩種最常見的失敗 —— 2026-09-05 之前它們會讓你拿到
    # 一套半新半舊的遊戲,外加一個 Python 原始 traceback(實測過)。
    targets = {}
    for rel in blobs:
        t = safe_target(root, rel)
        if not t:
            raise Stop('路徑不安全:%s' % clean_text(rel, 160))
        targets[rel] = t
    for rel, t in targets.items():
        bak = t + INSTALL_BAK
        # 要蓋掉的那個遊戲檔**本身**是符號連結的話,整支停下來。
        # ⚠️ 這一道不可以靠 safe_target:它會 realpath,連結早就被解開成真正的
        #    落點了,t 永遠不是連結 —— 也就是說「解開之後照樣寫下去」。
        #    2026-09-06 實測舊版:把 data/database/attrib.dat 換成一個指向
        #    data/real_target.bin 的連結,--install --apply 回 0、印 ✅,
        #    真正被改掉的是 real_target.bin,而 attrib.dat 還是那個連結,
        #    .packbak 也留在 real_target.bin 旁邊。
        #    (連結指到遊戲資料夾**外面**時,safe_target 會擋,但擋下來的理由是
        #     「逃出資料夾」,跟這一道不是同一件事,訊息也不對。)
        #    路徑**中間**的資料夾是連結沒關係(有人把遊戲放在別顆碟),
        #    只看最後那一段 —— os.path.islink 問的正好就是最後那一段。
        refuse_if_link(os.path.join(root, rel), '要蓋掉的遊戲檔')
        # 備份檔與收據是等一下要寫的位置,先問它們是不是符號連結。
        refuse_if_link(bak, '舊的備份檔')
        refuse_if_link(t + BAK_RECEIPT, '舊的備份收據')
        # 這個檔旁邊已經有 .packbak 的話,那就是你**唯一**的退路,
        # 裝新東西之前先確認它是完整的。2026-09-05 之前完全不驗:
        # 一份已經壞掉的 .packbak 不會擋下安裝,於是你在「沒有退路」的狀態下
        # 又蓋了一層新的,而且從頭到尾沒有人講。
        if os.path.lexists(bak):
            verdict, why = check_backup(bak, t)
            if verdict == 'refuse':
                raise Stop('%s 旁邊那份舊的 %s%s\n'
                           '  它是你退回原狀的唯一依靠,壞的話不敢再往上蓋。\n'
                           '  一個檔都還沒有被改到。先處理那份備份(確定原檔沒問題\n'
                           '  就把 %s 與 %s 一起刪掉,這支會重新留一份)。'
                           % (clean_text(rel, 160), os.path.basename(bak), why,
                              os.path.basename(bak), os.path.basename(t + BAK_RECEIPT)))
            if verdict == 'unverified':
                print('  ⚠️ %s 旁邊那份舊的 %s%s'
                      % (clean_text(rel, 160), os.path.basename(bak), why))
        try:
            os.makedirs(os.path.dirname(t), exist_ok=True)
            # 試建一個暫存檔再刪掉:這一關看的是資料夾寫不寫得進去。
            # 名字由 mkstemp 給,不是 t + '.probe' —— 那個名字猜得到,
            # 先擺一個同名的檔在那裡會被截成 0 再刪掉(它如果是連結,
            # 被清掉的是連結指向的那個檔)。2026-09-05 之前就是這樣寫的。
            fd, probe = _mktemp_beside(t, 'probe')
            os.close(fd)
            os.remove(probe)
            # 已經在那裡的檔要另外試:Windows 上「檔案正被遊戲開著」
            # 不會反映在資料夾權限上,要真的開一次才知道。
            # 'r+b' 只是開起來就關掉,不寫任何東西。
            if os.path.exists(t):
                with open(t, 'r+b'):
                    pass
        except OSError as e:
            raise Stop('%s 這個位置寫不進去:%s\n'
                       '  常見原因:遊戲正開著、或者遊戲裝在需要系統管理員權限的地方。\n'
                       '  先把遊戲關掉再試一次。一個檔都還沒有被改到。'
                       % (clean_text(rel, 160), clean_text(e, 200)))

    # 第三階段:真的寫。這一段自己又分成兩半(2026-09-06 第三輪改的):
    #   備妥  每個檔各留一份備份,再各寫好一個**已經驗過**的暫存檔。
    #         這一整段**一個遊戲檔都沒有動**,所以磁碟寫滿、權限中途被改掉
    #         這一類失敗,結果是「全部沒裝」而不是「裝了一半」。
    #   換名  全部備妥了才換,而且整批換名包在**同一段**不可中斷的程式裡
    #         (見 _NoInterrupt),Ctrl-C 不會落在兩個檔中間。
    #         中途某一次換名失敗,照 .packbak 把已經換掉的退回去。
    # journal 是這一輪的交易日誌:每換掉一個遊戲檔就記一筆(見 _rollback)。
    wrote = 0
    journal = []
    staged = []          # [(暫存檔, 目的檔, 備份檔, 本來有沒有這個檔)]
    try:
        for rel, data in blobs.items():
            t = targets[rel]
            bak = t + INSTALL_BAK
            existed = os.path.isfile(t)
            # 只在第一次備份。已經有 .packbak 就保留最早那一份,那才是你原本的檔;
            # 蓋掉的話,裝了第二個模組之後就再也退不回原始狀態了。
            # 備份走的是跟寫檔同一套原子流程:名字猜不到的暫存檔 → fsync →
            # 讀回來逐位元組比對 → 才換上去。備份是還原時唯一的依靠,
            # 少了 fsync,斷電時改上去的 .packbak 仍然可能是 0 或半截的。
            # ⚠️ 備妥到一半失敗時,這一輪已經留下的 .packbak **不刪**:
            #    它的內容就是現在那個沒被動過的遊戲檔,留著不會害到任何人,
            #    下次真的裝的時候用的還是同一份原始檔。
            if existed and not os.path.lexists(bak):
                before = sha256(t)
                atomic_copy_over(bak, t, tag='packbak', want_digest=before)
                # 收據:記下這份備份的大小與 SHA-256,--restore 靠它判斷完不完整。
                write_receipt(t + BAK_RECEIPT, os.path.getsize(bak), before)
            staged.append((_prepare_bytes(t, data, tag='install'), t, bak, existed))
        with _NoInterrupt():
            for tmp, t, bak, existed in staged:
                _swap(tmp, t, record=True)
                journal.append({'t': t, 'bak': bak, 'existed': existed})
                wrote += 1
    except Exception as e:
        # 寫到一半才失敗(Stop 也算,它可能是「讀回來對不上」丟出來的)。
        # 先把還沒換上去的暫存檔收乾淨,再自動退回,
        # 然後講清楚現在是什麼狀態 —— 不假裝沒事,也不印 ✅。
        # ⚠️ 「有沒有動到遊戲檔」要問 _MUTATED / _INFLIGHT,不可以只看 wrote:
        #    _swap 換完之後、wrote += 1 之前還有一個縫(跟下面 BaseException
        #    那一段同一個理由)。而且這一行要算在 _rollback **之前** ——
        #    退回去之後那幾個檔的內容雖然回到原狀,它們確實被換過。
        touched = [t for t in targets.values() if t in _MUTATED or t in _INFLIGHT]
        done = len(touched)
        _drop_staged(staged, wrote)
        back, bad = _rollback(journal)
        # 「先全部備妥、再一次換完」之後,最常見的失敗變成「備妥階段就出事」,
        # 那時一個遊戲檔都還沒換過:第一句照舊說「寫到一半失敗了」是假的,
        # 而且底下那幾道閘門本來全掛在 wrote 上,wrote 是 0 的時候玩家
        # 一個字都收不到,不會有人告訴他遊戲檔沒被動到。
        # 2026-09-06 第三輪把這一整段改成問**狀態**(_MUTATED / _INFLIGHT),
        # 不再問計數器 —— 理由跟下面 BaseException 那一段一模一樣。
        msg = ['%s:%s' % ('寫到一半失敗了' if done else '還沒開始換檔就失敗了', e),
               '  已經裝進去 %d 個檔,剩下 %d 個沒動到。' % (done, len(blobs) - done)]
        if journal:
            msg.append('  自動退回了 %d 個(退回的是**原始**的遊戲檔,跟 --restore 一樣)。'
                       % back)
        if bad:
            msg.append('  ⚠️ 有 %d 個退不回去,這幾個要你自己處理:' % len(bad))
            msg.extend('       %s' % b for b in bad)
            msg.append('  備份都還留著。再跑一次 --restore 試試看。')
        elif not done:
            # 一個檔都沒換過才敢講這一句(SPEC 第 3 節:換名之後就不可以這樣說)。
            msg.append('  遊戲檔一個位元組都沒有動。')
        elif back == done:
            msg.append('  現在這套遊戲跟你裝之前一樣。')
        else:
            # 換掉了、卻沒有進到交易日誌裡:退回去的那一輪碰不到它。
            # (要落在 _swap 換完之後、journal.append 之前那一個縫才會發生。)
            msg.append('  ⚠️ 有 %d 個檔已經被換掉,而且沒有退回去。'
                       '跑一次 --restore 退回去。' % (done - back))
        raise Stop('\n'.join(msg))
    except BaseException:
        # Ctrl-C 這一類不是 Exception 的東西:**不**自動退回
        # (退回本身是一連串寫入,再被中斷只會更亂),但一定要逐檔講清楚
        # 哪些已改、哪些沒改,不可以只接 Exception 就讓它安靜地跑掉。
        # 結束碼由最外層的 KeyboardInterrupt 收尾決定(130),不會是 0。
        # ⚠️ 逐檔的狀態要問 _MUTATED / _INFLIGHT,**不可以**拿 wrote 這個計數器判:
        #    中斷可以剛好落在 _swap 已經換完、而 wrote += 1 還沒跑到的那一瞬間,
        #    那時 wrote 還是 0,照著它印就會對一個真的被換掉的檔說「沒改」——
        #    正是這一輪要修掉的那種說謊。2026-09-06 自己先寫錯過一次,
        #    餌 9b 當場抓到(檔案內容已經是新的,而這裡印「一個都沒有被動到」)。
        # 列的是**這一包裡的每一個檔**,不是只列已經備妥的那幾個:
        # 中斷可以落在備妥階段,那時 staged 還是半條清單,只印它會漏講後面那些。
        _drop_staged(staged, wrote)
        print()
        print('  ⚠️ 中斷了。這一輪逐檔的狀態:')
        moved = 0
        for rel in blobs:
            t = targets[rel]
            if t in _MUTATED:
                mark = '已改  '
                moved += 1
            elif t in _INFLIGHT:
                mark = '正在換'
                moved += 1
            else:
                mark = '沒改  '
            print('      %s  %s' % (mark, clean_text(rel, 160)))
        if moved:
            print('  已經改掉的(或正在改的)那幾個,跑一次 --restore 退回去。')
        else:
            print('  一個遊戲檔都沒有被動到。')
        raise
    print('  ✅ 裝好了 %d 個檔。原本的檔案備份成 %s,旁邊那個 %s 是備份的收據。'
          % (wrote, INSTALL_BAK, BAK_RECEIPT))
    print('     要退回去:--restore')
    return 0


def cmd_restore(game_root, allow_unverified=False):
    """--restore:把整個遊戲資料夾裡所有 .packbak 蓋回去。

    只認得這支工具自己留下的 .packbak,不會去碰別課留下的備份
    (那些各課有自己的 --restore)。備份留著不刪,所以可以再裝一次。

    蓋回去之前每一份備份都要先過 check_backup():0 bytes 的、跟收據對不上的、
    格式上明顯半截的、以及符號連結,一律不蓋,那個遊戲檔維持原狀,結束碼是 1。
    2026-09-05 之前這裡只擋 0 bytes,一份被截成 60% 的備份會被照樣蓋上去,
    收尾還印 ✅ —— 實測把 840,643 bytes 的 attrib.dat 換成了 504,385 bytes。

    ⚠️ 「沒有收據、格式也驗不出證據」那一種(check_backup 回 'unverified'),
       2026-09-05 第二輪起**預設不蓋**:驗不出來就當成不能信,寧可什麼都不做。
       確定那一份其實是完整的,再加上 --allow-unverified 跑一次。
       (之前是照樣蓋回去只印一行 ⚠️ —— 一份格式驗不出來的半截備份
        會被安靜地蓋到遊戲檔上。)

    寫入本身是原子的:先寫一個名字猜不到的暫存檔、讀回來跟備份逐位元組比對,
    通過了才 os.replace 換上去。2026-09-05 之前是 copy2 直接蓋,
    量到的樣子寫在檔頭安全網第 5 條。
    """
    root = os.path.realpath(game_root)
    done = 0          # 蓋回去了幾個
    unverified = 0    # 其中幾個是「沒驗到」就蓋的(要 --allow-unverified 才會有)
    refused = 0       # 幾個因為備份沒過檢查而沒蓋
    skipped = 0       # 幾個是「驗不出來」而被跳過的
    failed = 0        # 幾個是真的寫下去才失敗的(磁碟滿了、權限被改掉…)
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if not d.startswith('.')]
        for fn in sorted(filenames):
            if not fn.endswith(INSTALL_BAK):
                continue
            bak = os.path.join(dirpath, fn)
            orig = os.path.join(dirpath, fn[:-len(INSTALL_BAK)])
            verdict, why = check_backup(bak, orig)
            if verdict == 'refuse':
                print('  🔴 %s %s' % (clean_text(fn, 160), why))
                print('     不敢拿它覆蓋 %s,這個檔維持原狀。'
                      % clean_text(os.path.basename(orig), 160))
                refused += 1
                continue
            if verdict == 'unverified':
                if not allow_unverified:
                    # 驗不出來就當成不能信。備份留在原地、遊戲檔一個位元組都不動,
                    # 什麼都不做永遠比「蓋一份可能是半截的東西上去」安全。
                    print('  ⏭ %s %s' % (clean_text(fn, 160), why))
                    print('     驗不出來的預設不蓋,%s 維持原狀。'
                          % clean_text(os.path.basename(orig), 160))
                    print('     你確定它是完整的,加上 --allow-unverified 再跑一次。')
                    skipped += 1
                    continue
                print('  ⚠️ %s %s' % (clean_text(fn, 160), why))
                print('     你自己加了 --allow-unverified,所以照樣蓋回去。')
                unverified += 1
            # 原子還原:先寫一個名字猜不到的暫存檔、讀回來跟備份逐位元組比對,
            # 通過了才在不可中斷段裡一次改名換上去。中途被中斷只會留下那個暫存檔,
            # 遊戲檔要嘛是舊的要嘛是新的,不會是半截的。
            try:
                atomic_copy_over(orig, bak, tag='restore', record=True)
            except (OSError, Stop) as e:
                # 這**一個**檔還原失敗(磁碟滿了、權限中途被改掉、讀回來對不上)。
                # 不整支中斷:剩下的檔還原得了就還原,最後一起算帳、結束碼 1。
                # 2026-09-06 之前這裡什麼都沒接,一個 OSError 就是一整片
                # Python 原始 traceback 噴到玩家臉上,而且不會講其他檔怎麼了。
                # ⚠️ Ctrl-C 不走這裡(它不是 Exception),交給最外層誠實收尾。
                print('  🔴 %s 沒還原成功:%s'
                      % (clean_text(os.path.basename(orig), 160), clean_text(e, 300)))
                print('     這個檔維持原狀,備份留著沒刪。')
                failed += 1
                continue
            done += 1
    if not done and not refused and not skipped and not failed:
        raise Stop('找不到任何 %s 備份 —— 你還沒用這支工具裝過東西。' % INSTALL_BAK)
    print()
    if refused or skipped or failed:
        # 有東西沒還原成功就不可以印 ✅,結束碼也不可以是 0。
        if refused:
            print('  🔴 有 %d 份備份沒過完整性檢查,那幾個遊戲檔一個位元組都沒動。'
                  % refused)
        if skipped:
            print('  ⏭ 有 %d 份備份沒有收據、格式也驗不出證據,預設不蓋,'
                  '那幾個遊戲檔一個位元組都沒動。' % skipped)
            print('     確定它們是完整的:再跑一次,加上 --allow-unverified。')
        if failed:
            print('  🔴 有 %d 個檔寫下去才失敗,那幾個遊戲檔維持原狀'
                  '(換名之前就停住了)。' % failed)
        if done:
            print('     另外 %d 個檔還原好了(其中 %d 個是你自己放行的沒驗到的)。'
                  % (done, unverified))
        print('     備份都還留著。你確定某一份其實是完整的,自己把它複製回去。')
        return 1
    print('  ✅ 還原了 %d 個檔。備份留著沒刪。' % done)
    if unverified:
        print('  ⚠️ 其中 %d 個沒有收據,只驗得出「不是 0 bytes、看不出明顯的半截」,'
              '是你自己加 --allow-unverified 放行的。' % unverified)
    return 0


# ── 自我測試 ────────────────────────────────────────────────
# 檔尾那幾行 assert 每次執行都會跑,但它們只驗得到「純函式」那一層。
# 這一段是 --selftest 才跑的:替會動到磁碟的守門下餌(不是每一道都有,見檔頭 --selftest 那一段),
# 證明它真的會擋 —— 不是「看起來有寫」。
# ⚠️ 反向測試沒有產生可見的變化,等於測試本身失敗:所以每個餌都要驗
#    「不該被動的那個檔真的沒被動」,不是只驗「有丟例外」。
# 全部在系統暫存區自己造一份假的遊戲資料夾,跑完就刪,不碰任何真的遊戲檔。


class _Hush:
    """把被測那幾支印的東西吞起來,自我測試只印自己的結果。"""

    def __init__(self):
        self.buf = []

    def write(self, text):
        self.buf.append(text)
        return len(text)

    def flush(self):
        pass

    def __enter__(self):
        self._old = sys.stdout
        sys.stdout = self
        return self

    def __exit__(self, *_a):
        sys.stdout = self._old
        return False

    @property
    def text(self):
        return ''.join(self.buf)


def _st_game(tmp, name, entries):
    """造一個假的遊戲資料夾。entries 是 {相對路徑: 內容}。"""
    root = os.path.join(tmp, name)
    os.makedirs(os.path.join(root, 'data', 'database'), exist_ok=True)
    for rel, blob in entries.items():
        p = os.path.join(root, rel.replace('/', os.sep))
        os.makedirs(os.path.dirname(p), exist_ok=True)
        with open(p, 'wb') as f:
            f.write(blob)
    return root


def _st_mod(path, entries):
    """手工打一個 .mvpmod,雜湊都寫對(不經過 cmd_build,免得互相掩護)。"""
    files = []
    with zipfile.ZipFile(path, 'w') as z:
        for rel, blob in entries:
            files.append({'path': rel, 'size': len(blob),
                          'sha256': hashlib.sha256(blob).hexdigest()})
            z.writestr('files/' + rel, blob)
        z.writestr(MANIFEST, json.dumps(
            {'format': 'mvpmod/1', 'name': '自我測試', 'files': files}))
    return path


def _st_mod_raw(path, manifest, members, deflate=False):
    """手工打一個 .mvpmod,manifest 與每個項目都由呼叫的人全權決定(包括故意寫錯的)。

    members 是 [(壓縮檔裡的完整名字, 內容)],名字要自己帶 'files/' 前綴。
    """
    comp = zipfile.ZIP_DEFLATED if deflate else zipfile.ZIP_STORED
    with zipfile.ZipFile(path, 'w', comp) as z:
        z.writestr(MANIFEST, json.dumps(manifest))
        for name, blob in members:
            z.writestr(name, blob)
    return path


def _st_lie_size(path, member, new_size):
    """把 .mvpmod 目錄上 member 的 file_size 改成 new_size(內容不動),模擬目錄說謊的包。"""
    import struct
    with open(path, 'rb') as f:
        raw = bytearray(f.read())
    pos, hit = 0, False
    while True:
        i = raw.find(b'PK\x01\x02', pos)
        if i < 0:
            break
        n_len, x_len, c_len = struct.unpack('<HHH', raw[i + 28:i + 34])
        if bytes(raw[i + 46:i + 46 + n_len]).decode('utf-8') == member:
            struct.pack_into('<I', raw, i + 24, new_size)
            hit = True
        pos = i + 46 + n_len + x_len + c_len
    if not hit:
        raise AssertionError('自我測試的夾具壞了:目錄裡找不到 %s' % member)
    with open(path, 'wb') as f:
        f.write(bytes(raw))
    return path


_MISSING = object()   # 餌22 用:「這個欄位根本沒寫」跟「寫了 None」是兩回事


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


# 餌的名單。這裡是「餌數」的**唯一真相來源**:跳過的餌要照**名字**扣掉,
# 不可以拿「跳過了幾則訊息」當扣除量。
# ⚠️ 2026-09-06 第三輪之前扣的是訊息數,而「餌11 / 餌12」共用一則訊息只放一筆
#    (它們一起需要 signal.raise_signal),於是 Python 3.7 以下實測印
#    「陰性對照 2 項 + 餌 14 項」而真的跑過的只有 13 個 —— 多報一個。
BAIT_NAMES = ('餌1', '餌2', '餌3', '餌4', '餌4b', '餌5', '餌6', '餌7',
              '餌8', '餌9b', '餌9', '餌10', '餌11', '餌12', '餌13', '餌15',
              '餌14', '餌16', '餌17', '餌18', '餌19', '餌20', '餌21', '餌22', '餌23',
              '餌24', '餌25', '餌26', '餌27')


def _bait_tally(skipped):
    """算出「這一次真的跑過幾個餌」。skipped 收的是**餌名**,不是訊息。

    多報一個餌就是「只回報自己跑過的」那條紀律要擋的過度宣稱,
    所以這裡不只相減:名字不在 BAIT_NAMES 裡、或者同一個名字記了兩次,
    一律當場停下來,不會安靜地算出一個好看的數字。
    """
    unknown = [n for n in skipped if n not in BAIT_NAMES]
    if unknown:
        raise Stop('自我測試的跳過清單有不存在的餌名:%s' % '、'.join(unknown))
    if len(set(skipped)) != len(skipped):
        raise Stop('自我測試的跳過清單有重複的餌名:%s' % '、'.join(skipped))
    return len(BAIT_NAMES) - len(skipped)


def _selftest():
    """跑所有的餌。全綠回 0,任何一個沒抓到回 1,在 python -O 底下回 2。"""
    if _optimize_level():
        # -O 會把 assert 整句拿掉:檔尾那十幾行「每次執行都會跑」的 assert
        # 一句都不會執行,自我測試就變成假綠燈 —— 什麼都沒測到卻印 ✅。
        # 2026-09-06 實測 2026-09-05 那一版:python3 -I -O mvp_pack.py --selftest
        # 照樣印「全綠」、結束碼 0。所以這裡直接停下來,不給那個假綠燈。
        print()
        print('  🔴 --selftest 不能在 python -O 下跑:'
              '-O 會把 assert 全部拿掉,測試會假綠。')
        print('     把 -O 拿掉再跑一次(python3 -I mvp_pack.py --selftest)。')
        print()
        return 2
    # 這一行要緊接在守門後面:_optimize_bait() 的陰性對照靠它收工(見那個函式)。
    _opt = _optimize_bait(_selftest)
    fails = []
    skips = []       # 這個 Python 版本做不到而跳過的餌:一個餌一筆 (餌名, 為什麼)。
    #                  一筆講兩個餌會讓下面的餌數多報一個,見 _bait_tally。
    # ── 餌16:最上面那道 -O 守門(開頭那一行 _optimize_bait() 已經量好了,這裡只記帳) ──
    if not _opt[0]:
        fails.append('餌16 沒抓到:假裝開了 -O(把 _optimize_level 換成回傳 1),'
                     '自我測試竟然沒有拒跑')
    # ── 陰性對照 3:換回原本那一支之後,沒開 -O 就要放行 ──────────
    if not _opt[1]:
        fails.append('陰性對照 3:沒開 -O 守門也擋,或是換回原本那一支沒換成功')
    ATT = b'data/database/attrib.dat'
    tmp = tempfile.mkdtemp(prefix='mvp_pack_selftest-')
    try:
        # ── 陰性對照:整條路走得通,不然下面每個餌都是假綠燈 ──────
        g = _st_game(tmp, 'ok', {'data/database/attrib.dat': b'old attrib\n'})
        mod = _st_mod(os.path.join(tmp, 'ok.mvpmod'),
                      [('data/database/attrib.dat', b'new attrib\n')])
        with _Hush():
            rc = cmd_install(g, mod, True)
        att = os.path.join(g, 'data', 'database', 'attrib.dat')
        if rc != 0 or _st_read(att) != b'new attrib\n':
            fails.append('陰性對照:一般安裝就失敗了(rc=%s)' % rc)
        if not os.path.isfile(att + INSTALL_BAK):
            fails.append('陰性對照:沒有留下 %s' % INSTALL_BAK)
        with _Hush():
            rc = cmd_restore(g)
        if rc != 0 or _st_read(att) != b'old attrib\n':
            fails.append('陰性對照:還原沒有回到原本的內容(rc=%s)' % rc)
        if not _MUTATED:
            fails.append('陰性對照:換過檔卻沒有記進 _MUTATED,'
                         'Ctrl-C 收尾會說謊')

        # ── 陰性對照 2:--build 打得出包,而且驗得過 ──────────────
        g2 = _st_game(tmp, 'build', {
            'data/database/attrib.dat': b'changed\n',
            'data/database/attrib.dat.playerbak': b'original\n'})
        outmod = os.path.join(tmp, 'out.mvpmod')
        with _Hush():
            rc = cmd_build(g2, outmod, 'n', 'a', '1.0', '', [])
        if rc != 0 or not os.path.isfile(outmod):
            fails.append('陰性對照:--build 打不出包')

        # ── 餌 1:NTFS 的另一條資料流(冒號不在第二個字元) ────────
        if is_safe_relpath('data/file.txt:payload'):
            fails.append('餌1 沒抓到:data/file.txt:payload 竟然算安全路徑')
        if not is_safe_relpath('data/file.txt'):
            fails.append('餌1 誤殺:正常路徑被冒號那一道擋掉了')

        # ── 餌 2:先擺好的暫存檔名符號連結,不可以動到資料夾外的檔 ──
        # 這是 2026-09-05 第二輪最主要的那一條:.part / .tmp / .probe
        # 這些名字都猜得到,先擺一個同名的連結指到外面,舊版就會跟著寫過去。
        outside = os.path.join(tmp, 'outside.txt')
        with open(outside, 'wb') as f:
            f.write(b'DO NOT TOUCH ME' * 100)
        before = _st_read(outside)
        g3 = _st_game(tmp, 'link', {'data/database/attrib.dat': b'old\n'})
        a3 = os.path.join(g3, 'data', 'database', 'attrib.dat')
        for bait in (a3 + '.tmp', a3 + '.probe', a3 + '.part',
                     a3 + INSTALL_BAK + '.part', a3 + BAK_RECEIPT + '.part'):
            os.symlink(outside, bait)
        mod3 = _st_mod(os.path.join(tmp, 'link.mvpmod'),
                       [('data/database/attrib.dat', b'brand new\n')])
        with _Hush():
            try:
                cmd_install(g3, mod3, True)
            except Stop as e:
                fails.append('餌2:正常安裝被擋掉了(%s)' % e)
        if _st_read(outside) != before:
            fails.append('餌2 沒抓到:資料夾外的檔被 .tmp/.probe 連結帶著改掉了')
        if _st_read(a3) != b'brand new\n':
            fails.append('餌2:遊戲檔沒有被正確寫入')
        with _Hush():
            cmd_restore(g3)
        if _st_read(outside) != before:
            fails.append('餌2 沒抓到:還原時資料夾外的檔被 .part 連結帶著改掉了')

        # ── 餌 3:還原到一半失敗,正本必須原封不動 ────────────────
        g4 = _st_game(tmp, 'half', {'data/database/attrib.dat': b'old\n'})
        a4 = os.path.join(g4, 'data', 'database', 'attrib.dat')
        mod4 = _st_mod(os.path.join(tmp, 'half.mvpmod'),
                       [('data/database/attrib.dat', b'installed\n')])
        with _Hush():
            cmd_install(g4, mod4, True)
        real_replace = os.replace
        MARK = '自我測試故意讓 os.replace 失敗'

        def _always_fail(_src, _dst):
            raise OSError(MARK)

        os.replace = _always_fail
        try:
            with _Hush() as h4:
                rc4 = cmd_restore(g4)
            if rc4 == 0:
                fails.append('餌3 沒抓到:os.replace 都失敗了,--restore 竟然回 0')
            # 這個餌一定要**真的走到寫檔那一步**才算數:如果是被前面別的檢查
            # 攔下來的,測到的就不是「還原到一半失敗」這件事。
            if MARK not in h4.text:
                fails.append('餌3 是被別的東西擋掉的,還原那一步根本沒被測到(%r)'
                             % h4.text[:200])
        except BaseException as e:
            # 2026-09-06 起 --restore 逐檔接住 OSError / Stop,自己講人話、
            # 結束碼 1;原始例外噴到玩家臉上就是退步。
            fails.append('餌3:--restore 不該把原始例外丟給玩家(%r)' % e)
        finally:
            os.replace = real_replace
        if _st_read(a4) != b'installed\n':
            fails.append('餌3 沒抓到:還原失敗卻動到了正本(現在是 %r)'
                         % _st_read(a4)[:20])
        junk = [n for n in os.listdir(os.path.dirname(a4))
                if n.startswith('.attrib.dat.')]
        if junk:
            fails.append('餌3:失敗之後沒有把暫存檔收乾淨,留下 %s' % junk)

        # ── 餌 4:.packbak 是符號連結 ────────────────────────────
        # ⚠️ 這個餌一定要直接問 check_backup 拿「理由」,不可以只看
        #    cmd_restore 的結束碼:連結指到的那個檔剛好也過不了別的規則時,
        #    整支照樣回 1,符號連結那一道被拿掉也看不出來(實測過)。
        g5 = _st_game(tmp, 'baklink', {'data/database/attrib.dat': b'real\n'})
        a5 = os.path.join(g5, 'data', 'database', 'attrib.dat')
        os.symlink(outside, a5 + INSTALL_BAK)
        verdict, why = check_backup(a5 + INSTALL_BAK, a5)
        if verdict != 'refuse' or '符號連結' not in why:
            fails.append('餌4 沒抓到:.packbak 是符號連結,判定卻是 %s(%s)'
                         % (verdict, why))
        with _Hush():
            rc = cmd_restore(g5)
        if rc == 0:
            fails.append('餌4 沒抓到:.packbak 是符號連結竟然照樣還原')
        if _st_read(a5) != b'real\n':
            fails.append('餌4 沒抓到:遊戲檔被連結指到的內容蓋掉了')
        if _st_read(outside) != before:
            fails.append('餌4 沒抓到:資料夾外的檔被動到了')
        # 反過來:遊戲檔自己是符號連結,也不可以蓋 —— 蓋回去只會把連結換成
        # 普通檔,連結指向的那一份反而原封不動,兩邊都不是玩家要的結果。
        g5b = _st_game(tmp, 'origlink', {'data/database/attrib.dat': b'real\n'})
        link5 = os.path.join(g5b, 'data', 'link.dat')
        os.symlink(outside, link5)
        with open(link5 + INSTALL_BAK, 'wb') as f:
            f.write(b'whatever\n')
        verdict, why = check_backup(link5 + INSTALL_BAK, link5)
        if verdict != 'refuse' or '符號連結' not in why:
            fails.append('餌4b 沒抓到:遊戲檔是符號連結,判定卻是 %s(%s)'
                         % (verdict, why))

        # ── 餌 5:沒有收據又驗不出格式的備份,預設不可以蓋 ──────────
        g6 = _st_game(tmp, 'unver', {
            'data/database/attrib.dat': b'text\n',
            'data/x.bin': b'\x00\x01\x02\x03now',
            'data/x.bin' + INSTALL_BAK: b'\x00\x01\x02\x03older-and-longer'})
        x6 = os.path.join(g6, 'data', 'x.bin')
        with _Hush() as h:
            rc = cmd_restore(g6)
        if rc == 0:
            fails.append('餌5 沒抓到:驗不出來的備份預設竟然照樣蓋,而且回 0')
        if _st_read(x6) != b'\x00\x01\x02\x03now':
            fails.append('餌5 沒抓到:驗不出來的備份被蓋回去了')
        if 'allow-unverified' not in h.text:
            fails.append('餌5:訊息裡沒有告訴玩家怎麼放行')
        with _Hush():
            rc = cmd_restore(g6, allow_unverified=True)
        if rc != 0 or _st_read(x6) != b'\x00\x01\x02\x03older-and-longer':
            fails.append('餌5 反向:自己放行之後應該要蓋得回去(rc=%s)' % rc)

        # ── 餌 6:舊的 .packbak 壞掉,不可以讓新的安裝繼續 ──────────
        g7 = _st_game(tmp, 'badbak', {'data/database/attrib.dat': b'origin\n'})
        a7 = os.path.join(g7, 'data', 'database', 'attrib.dat')
        mod7 = _st_mod(os.path.join(tmp, 'badbak.mvpmod'),
                       [('data/database/attrib.dat', b'first mod\n')])
        with _Hush():
            cmd_install(g7, mod7, True)
        with open(a7 + INSTALL_BAK, 'wb') as f:      # 把備份截半
            f.write(b'ori')
        mod7b = _st_mod(os.path.join(tmp, 'badbak2.mvpmod'),
                        [('data/database/attrib.dat', b'second mod\n')])
        try:
            with _Hush():
                cmd_install(g7, mod7b, True)
            fails.append('餌6 沒抓到:備份已經壞掉,第二個模組竟然照裝')
        except Stop:
            pass
        if _st_read(a7) != b'first mod\n':
            fails.append('餌6 沒抓到:被擋下來了卻還是動到了遊戲檔')

        # ── 餌 7:--build 驗不過,不可以在輸出位置留下任何東西 ──────
        g8 = _st_game(tmp, 'badbuild', {
            'data/database/attrib.dat': b'changed\n',
            'data/database/attrib.dat.playerbak': b'original\n'})
        out8 = os.path.join(tmp, 'badbuild.mvpmod')
        real_sha = globals()['sha256']
        globals()['sha256'] = lambda p: real_sha(p)[::-1]   # 每個檔都算錯,但仍然各不相同
        try:
            with _Hush():
                cmd_build(g8, out8, 'n', 'a', '1.0', '', [])
            fails.append('餌7 沒抓到:manifest 的雜湊全是錯的,--build 竟然說成功')
        except Stop:
            pass
        finally:
            globals()['sha256'] = real_sha
        if os.path.lexists(out8):
            fails.append('餌7 沒抓到:驗不過卻在輸出位置留下了 %s'
                         % os.path.basename(out8))

        # ── 餌 8:多檔安裝寫到一半失敗,已經寫進去的要自動退回 ──────
        g9 = _st_game(tmp, 'rollback', {
            'data/database/attrib.dat': b'A original\n',
            'data/database/other.dat': b'B original\n'})
        a9 = os.path.join(g9, 'data', 'database', 'attrib.dat')
        b9 = os.path.join(g9, 'data', 'database', 'other.dat')
        mod9 = _st_mod(os.path.join(tmp, 'rollback.mvpmod'),
                       [('data/database/attrib.dat', b'A new\n'),
                        ('data/database/other.dat', b'B new\n')])
        real_replace = os.replace

        def _fail_on_b(src, dst):
            if os.path.realpath(dst) == os.path.realpath(b9):
                raise OSError('自我測試故意讓第二個檔寫失敗')
            return real_replace(src, dst)

        os.replace = _fail_on_b
        try:
            with _Hush() as h9:
                cmd_install(g9, mod9, True)
            fails.append('餌8 沒抓到:第二個檔寫失敗竟然沒有報錯')
        except Stop as e:
            if '退回' not in str(e):
                fails.append('餌8:失敗訊息沒有講自動退回(%s)' % e)
            # 陰性對照:這裡是真的換掉一個檔之後才失敗的,
            # 第一句必須還是「寫到一半失敗了」,不可以被餌13 那一支的改法帶歪。
            if '寫到一半失敗了' not in str(e):
                fails.append('餌8:換掉一個檔之後才失敗,第一句卻不是'
                             '「寫到一半失敗了」(%s)' % e)
            if '遊戲檔一個位元組都沒有動' in str(e):
                fails.append('餌8 沒抓到:已經換掉一個檔了,竟然說一個位元組都沒動')
        finally:
            os.replace = real_replace
        if _st_read(a9) != b'A original\n':
            fails.append('餌8 沒抓到:第一個檔沒有被退回去(現在是 %r)'
                         % _st_read(a9)[:20])
        if _st_read(b9) != b'B original\n':
            fails.append('餌8:第二個檔本來就不該被動到')

        # ── 餌 9b:連不可中斷段都失效時,「正在換」那個中間態要接住 ────
        # 模擬最壞的情形:_NoInterrupt 裝不上訊號處理器(非主執行緒之類),
        # 於是 KeyboardInterrupt 真的落在 os.replace 成功之後、登記之前。
        # 這時 _MUTATED 一定是空的 —— 只有第三個狀態 _INFLIGHT 救得了,
        # 沒有它,收尾就會告訴玩家「一個檔都沒有被動到」,而檔案已經換掉了。
        g11 = _st_game(tmp, 'ctrlc', {'data/database/attrib.dat': b'before\n'})
        a11 = os.path.join(g11, 'data', 'database', 'attrib.dat')
        mod11 = _st_mod(os.path.join(tmp, 'ctrlc.mvpmod'),
                        [('data/database/attrib.dat', b'after\n')])
        real_replace = os.replace

        def _replace_then_boom(src, dst):
            real_replace(src, dst)
            if os.path.realpath(dst) == os.path.realpath(a11):
                raise KeyboardInterrupt
            return None

        os.replace = _replace_then_boom
        n11 = len(_MUTATED)
        hi11 = _Hush()
        try:
            with hi11:
                cmd_install(g11, mod11, True)
            fails.append('餌9b:模擬的 Ctrl-C 沒有傳出來,這個餌沒測到東西')
        except KeyboardInterrupt:
            pass
        finally:
            os.replace = real_replace
        # cmd_install 自己那份「逐檔已改 / 沒改」也不可以說謊。它一度是拿
        # wrote 這個計數器印的,而中斷剛好落在「換完了、wrote 還沒加」的那一刻,
        # 於是對一個真的被換掉的檔印「沒改」+「一個遊戲檔都沒有被動到」。
        if '正在換' not in hi11.text:
            fails.append('餌9b 沒抓到:逐檔狀態沒有把它列成「正在換」(%r)'
                         % hi11.text[-200:])
        if '一個遊戲檔都沒有被動到' in hi11.text:
            fails.append('餌9b 沒抓到:檔案已經是新的了,逐檔狀態卻說'
                         '「一個遊戲檔都沒有被動到」')
        # ⚠️ 兩個清單裡存的是 realpath(safe_target 解過的),
        #    而 a11 是 mkdtemp 給的路徑 —— 在 macOS 上 /var 是連到 /private/var 的
        #    連結,兩個字串不一樣。2026-09-06 第一次寫這個餌就是拿沒解過的路徑去比,
        #    測出「沒抓到」,其實守門好好的,錯的是量法。
        real_a11 = os.path.realpath(a11)
        if _st_read(a11) != b'after\n':
            fails.append('餌9b:這個餌應該讓檔案真的被換掉才有意義')
        if len(_MUTATED) != n11:
            fails.append('餌9b:這個餌模擬的是「登記那一行沒跑到」,'
                         '_MUTATED 不該增加')
        if real_a11 not in _INFLIGHT:
            fails.append('餌9b 沒抓到:檔案已經換掉了,兩個清單卻都沒記 —— '
                         'Ctrl-C 收尾會說「一個檔都沒有被動到」')
        else:
            with _Hush() as h11:
                report_interrupt()
            if '正在替換' not in h11.text or real_a11 not in h11.text:
                fails.append('餌9b:收尾沒有說「正在替換」那個檔(%r)'
                             % h11.text[:200])
            if '一個檔都沒有被動到' in h11.text:
                fails.append('餌9b 沒抓到:收尾竟然說「一個檔都沒有被動到」')
            _INFLIGHT.remove(real_a11)

        # ── 餌 9:預覽不可以動到任何東西,也不可以記進 _MUTATED ──────
        g10 = _st_game(tmp, 'preview', {'data/database/attrib.dat': b'keep\n'})
        a10 = os.path.join(g10, 'data', 'database', 'attrib.dat')
        mod10 = _st_mod(os.path.join(tmp, 'preview.mvpmod'),
                        [('data/database/attrib.dat', b'nope\n')])
        n_before = len(_MUTATED)
        with _Hush():
            cmd_install(g10, mod10, False)
        if _st_read(a10) != b'keep\n':
            fails.append('餌9 沒抓到:不加 --apply 竟然寫了')
        if len(_MUTATED) != n_before:
            fails.append('餌9 沒抓到:預覽竟然被記成「改過檔」')

        # ── 餌 10:要蓋掉的遊戲檔本身是符號連結 ─────────────────────
        # 舊版靠 safe_target 的 realpath 把連結解開,然後**照樣寫下去** ——
        # 2026-09-06 實測:回 0、印 ✅,真正被改掉的是連結指到的那個檔。
        # 連結指到遊戲資料夾外面的會被 safe_target 擋(理由是逃出資料夾),
        # 所以這個餌刻意讓它指到資料夾**裡面**,只有新加的那一道擋得住。
        g12 = _st_game(tmp, 'tgtlink', {'data/database/attrib.dat': b'origin\n'})
        inside12 = os.path.join(g12, 'data', 'real_target.bin')
        with open(inside12, 'wb') as f:
            f.write(b'I AM THE REAL FILE\n')
        keep12 = _st_read(inside12)
        link12 = os.path.join(g12, 'data', 'link.dat')
        os.symlink(os.path.join('.', 'real_target.bin'), link12)
        mod12 = _st_mod(os.path.join(tmp, 'tgtlink.mvpmod'),
                        [('data/link.dat', b'PWNED\n')])
        try:
            with _Hush():
                cmd_install(g12, mod12, True)
            fails.append('餌10 沒抓到:要蓋掉的遊戲檔是符號連結,竟然照裝')
        except Stop as e:
            if '符號連結' not in str(e):
                fails.append('餌10 是被別的東西擋掉的(%s)' % e)
        if _st_read(inside12) != keep12:
            fails.append('餌10 沒抓到:連結指到的那個檔被改掉了')
        if not os.path.islink(link12):
            fails.append('餌10:連結本身不該被動到')

        # ── 餌 11 / 12:真的對自己送一個 SIGINT(SPEC 的 T1 / T2) ────
        # 不是「丟一個 KeyboardInterrupt 假裝」,是真的訊號 —— 只有真訊號
        # 測得到 _NoInterrupt 有沒有把它擋在門外。
        # signal.raise_signal 是 Python 3.8 才有的,而且是兩個平台上唯一安全的
        # 送法(Windows 的 os.kill 送 SIGINT 會直接把行程 TerminateProcess 掉)。
        raise_sigint = getattr(signal, 'raise_signal', None)
        if raise_sigint is None:
            # ⚠️ 兩個餌各記一筆,不可以一筆講兩個 —— 下面扣的是餌名,
            #    一筆會少扣一個,印出來的餌數就比真的跑過的多一個。
            _why37 = '這個 Python 沒有 signal.raise_signal,要 3.8 以上才跑得了'
            skips.append(('餌11', '真訊號 Ctrl-C(落在換名之後):' + _why37))
            skips.append(('餌12', '真訊號 Ctrl-C(落在換名之前):' + _why37))
        else:
            # 餌 11(T1):訊號落在「os.replace 已經做完、登記還沒做」的那一刻。
            #            不可中斷段要先把它收著,等登記做完才丟出來。
            g13 = _st_game(tmp, 'sig1', {'data/database/attrib.dat': b'before\n'})
            a13 = os.path.join(g13, 'data', 'database', 'attrib.dat')
            mod13 = _st_mod(os.path.join(tmp, 'sig1.mvpmod'),
                            [('data/database/attrib.dat', b'after\n')])
            saved_m, saved_i = list(_MUTATED), list(_INFLIGHT)
            del _MUTATED[:]
            del _INFLIGHT[:]
            real_replace = os.replace

            def _replace_then_sigint(src, dst):
                real_replace(src, dst)
                if os.path.realpath(dst) == os.path.realpath(a13):
                    raise_sigint(signal.SIGINT)
                return None

            os.replace = _replace_then_sigint
            try:
                with _Hush():
                    cmd_install(g13, mod13, True)
                fails.append('餌11:訊號沒有變成 KeyboardInterrupt,沒測到東西')
            except KeyboardInterrupt:
                pass
            finally:
                os.replace = real_replace
            real_a13 = os.path.realpath(a13)     # 同餌9b 的理由:清單裡是 realpath
            if _st_read(a13) != b'after\n':
                fails.append('餌11:這個餌應該讓檔案真的被換掉才有意義')
            if real_a13 not in _MUTATED:
                fails.append('餌11 沒抓到:不可中斷段沒擋住訊號,'
                             '換掉的檔沒有被登記')
            if real_a13 in _INFLIGHT:
                fails.append('餌11:結果已經確定了,不該留在「正在換」')
            with _Hush() as h13:
                report_interrupt()
            if '已經有 1 個遊戲檔被換掉' not in h13.text or '--restore' not in h13.text:
                fails.append('餌11:收尾沒有照實說已經換掉、也沒叫他跑 --restore'
                             '(%r)' % h13.text[:120])
            del _MUTATED[:]
            del _INFLIGHT[:]

            # 餌 12(T2):訊號落在「暫存檔備妥了、還沒換名」的那一刻。
            #            這時遊戲檔一個位元組都還沒變,收尾就要說「沒動到」。
            g14 = _st_game(tmp, 'sig2', {'data/database/attrib.dat': b'keepme\n'})
            a14 = os.path.join(g14, 'data', 'database', 'attrib.dat')
            mod14 = _st_mod(os.path.join(tmp, 'sig2.mvpmod'),
                            [('data/database/attrib.dat', b'never\n')])
            real_prep = globals()['_prepare_bytes']
            fired = []

            def _prep_then_sigint(dst, data, tag='tmp'):
                out = real_prep(dst, data, tag=tag)
                if not fired and os.path.realpath(dst) == os.path.realpath(a14):
                    fired.append(1)
                    raise_sigint(signal.SIGINT)
                return out

            globals()['_prepare_bytes'] = _prep_then_sigint
            try:
                with _Hush():
                    cmd_install(g14, mod14, True)
                fails.append('餌12:訊號沒有變成 KeyboardInterrupt,沒測到東西')
            except KeyboardInterrupt:
                pass
            finally:
                globals()['_prepare_bytes'] = real_prep
            if _st_read(a14) != b'keepme\n':
                fails.append('餌12 沒抓到:換名之前就中斷了,遊戲檔卻被動到'
                             '(現在是 %r)' % _st_read(a14)[:20])
            if _MUTATED or _INFLIGHT:
                fails.append('餌12 沒抓到:什麼都沒換,卻登記成「換過 / 正在換」'
                             '—— 收尾會叫玩家白跑一次 --restore')
            with _Hush() as h14:
                report_interrupt()
            if '一個檔都沒有被動到' not in h14.text:
                fails.append('餌12:收尾沒有說「一個檔都沒有被動到」(%r)'
                             % h14.text[:120])
            _MUTATED[:] = saved_m
            _INFLIGHT[:] = saved_i

        # ── 餌 13:備妥階段就失敗,一個遊戲檔都不可以被動到 ──────────
        # 「先全部備妥、再一次換完」要做到的就是這個:寫暫存檔那一段出事
        #  (磁碟寫滿之類),結果是「全部沒裝」而不是「裝了一半再退回去」。
        g15 = _st_game(tmp, 'allornone', {
            'data/database/attrib.dat': b'A original\n',
            'data/database/other.dat': b'B original\n'})
        a15 = os.path.join(g15, 'data', 'database', 'attrib.dat')
        b15 = os.path.join(g15, 'data', 'database', 'other.dat')
        mod15 = _st_mod(os.path.join(tmp, 'allornone.mvpmod'),
                        [('data/database/attrib.dat', b'A new\n'),
                         ('data/database/other.dat', b'B new\n')])
        real_prep = globals()['_prepare_bytes']

        def _prep_fail_on_b(dst, data, tag='tmp'):
            if os.path.realpath(dst) == os.path.realpath(b15):
                raise Stop('自我測試故意讓第二個檔的暫存檔寫失敗')
            return real_prep(dst, data, tag=tag)

        globals()['_prepare_bytes'] = _prep_fail_on_b
        try:
            with _Hush():
                cmd_install(g15, mod15, True)
            fails.append('餌13 沒抓到:備妥階段失敗竟然沒有報錯')
        except Stop as e:
            if '已經裝進去 0 個檔' not in str(e):
                fails.append('餌13:訊息沒有照實說一個都沒裝進去(%s)' % e)
            # 備妥階段失敗 = 一個遊戲檔都還沒換過。第一句說「寫到一半」是假的,
            # 而且要真的把「沒動到」講出來 —— 舊版兩道閘門都掛在 wrote 上,
            # wrote 是 0 的時候玩家一個字都收不到。
            if '還沒開始換檔就失敗了' not in str(e):
                fails.append('餌13:一個檔都沒換過,第一句卻說「寫到一半」(%s)' % e)
            if '遊戲檔一個位元組都沒有動' not in str(e):
                fails.append('餌13:沒有告訴玩家遊戲檔一個位元組都沒被動到(%s)' % e)
        finally:
            globals()['_prepare_bytes'] = real_prep
        if _st_read(a15) != b'A original\n' or _st_read(b15) != b'B original\n':
            fails.append('餌13 沒抓到:備妥階段失敗,遊戲檔卻被動到了')
        junk15 = [n for n in os.listdir(os.path.dirname(a15))
                  if n.startswith('.attrib.dat.') or n.startswith('.other.dat.')]
        if junk15:
            fails.append('餌13:失敗之後沒有把暫存檔收乾淨,留下 %s' % junk15)

        # ── 餌 15:換完了、登記那一行沒跑到 —— 不可以說「0 個」 ──────
        # 跟餌 9b 同一種病,只是落在**例外**這一條路上(9b 走的是 Ctrl-C):
        # _swap 已經把檔案換掉,而 journal.append / wrote += 1 還沒跑到就爆掉。
        # 那一刻 wrote 還是 0,照著它印就會對一個真的被換掉的檔說「沒動到」——
        # 正是這一輪要修掉的那種說謊,所以失敗訊息改成問 _MUTATED / _INFLIGHT。
        g16 = _st_game(tmp, 'gap', {'data/database/attrib.dat': b'G original\n'})
        a16 = os.path.join(g16, 'data', 'database', 'attrib.dat')
        mod16 = _st_mod(os.path.join(tmp, 'gap.mvpmod'),
                        [('data/database/attrib.dat', b'G new\n')])
        real_swap = globals()['_swap']

        def _swap_then_boom(tmp16, dst, record=False):
            # 只讓**遊戲檔**那一次爆掉:備份與收據也走 _swap,
            # 把它們一起弄爆的話就變成備妥階段失敗,測到的是別的東西了。
            real_swap(tmp16, dst, record=record)
            if os.path.realpath(dst) == os.path.realpath(a16):
                raise RuntimeError('自我測試故意讓「登記」那一行沒跑到')

        saved_m16, saved_i16 = list(_MUTATED), list(_INFLIGHT)
        globals()['_swap'] = _swap_then_boom
        try:
            with _Hush():
                cmd_install(g16, mod16, True)
            fails.append('餌15 沒抓到:換名之後爆掉竟然沒有報錯')
        except Stop as e:
            if '已經裝進去 1 個檔' not in str(e):
                fails.append('餌15 沒抓到:檔案已經換掉了,訊息卻不是「1 個」(%s)' % e)
            if '遊戲檔一個位元組都沒有動' in str(e):
                fails.append('餌15 沒抓到:檔案已經換掉了,竟然說一個位元組都沒動')
            if '--restore' not in str(e):
                fails.append('餌15:已經換掉又沒退回去,卻沒叫玩家跑 --restore(%s)' % e)
        finally:
            globals()['_swap'] = real_swap
        if _st_read(a16) != b'G new\n':
            fails.append('餌15:這個餌應該讓檔案真的被換掉才有意義(現在是 %r)'
                         % _st_read(a16)[:20])
        _MUTATED[:] = saved_m16
        _INFLIGHT[:] = saved_i16

        # ── 餌17:包裡有會執行的程式碼,沒加 --allow-code 整包不裝 ──────
        # (2026-10-30,稽核 D2-02)mvp2005.exe / delete.bat / x.dll 的路徑完全合法、雜湊也對得上,
        # 舊版預覽上看起來跟資料檔一模一樣,--apply 照裝。
        # 這個餌的每一個斷言都先問「拿掉這道守門,這一行會不會紅」:遊戲檔一個位元組都不動、
        # 也沒有留下備份,才算真的擋住;只看結束碼不夠。
        EXE0, BAT0, ATT0 = b'ORIGINAL-EXE', b'@echo original\r\n', b'old attrib\n'
        g17 = _st_game(tmp, 'code', {'data/database/attrib.dat': ATT0,
                                      'mvp2005.exe': EXE0, 'delete.bat': BAT0})
        files17 = ('data/database/attrib.dat', 'mvp2005.exe', 'delete.bat')
        EVIL = b'evil exe, name only (no MZ in the content)'
        mod17 = _st_mod(os.path.join(tmp, 'code.mvpmod'),
                        [('data/database/attrib.dat', b'new attrib\n'),
                         ('mvp2005.exe', EVIL),
                         ('delete.bat', b'@echo evil\r\n'),
                         ('data/x.dll', b'dll bytes')])

        def _snap17():
            return [_st_read(os.path.join(g17, p.replace('/', os.sep))) for p in files17]

        want17 = _snap17()
        with _Hush() as h17:
            rc17 = cmd_install(g17, mod17, True)
        if rc17 == 0:
            fails.append('餌17 沒抓到:包裡有 mvp2005.exe / delete.bat / x.dll,'
                         '沒加 --allow-code 竟然回 0')
        for needle in ('會執行的程式碼', 'mvp2005.exe', 'delete.bat', 'x.dll',
                       hashlib.sha256(EVIL).hexdigest()):
            if needle not in h17.text:
                fails.append('餌17:預覽沒有把 %s 列出來' % needle)
        if _snap17() != want17:
            fails.append('餌17 沒抓到:被拒絕了,遊戲檔卻被動到')
        left17 = [n for _r, _d, fs in os.walk(g17) for n in fs
                  if n.endswith(INSTALL_BAK) or n.endswith(BAK_RECEIPT)]
        if left17:
            fails.append('餌17 沒抓到:被拒絕了卻留下備份 %s' % left17)
        with _Hush():
            if cmd_preview(g17, mod17) == 0:
                fails.append('餌17 沒抓到:--preview 沒有把這一包判成不能裝')
        # 第二道:假裝預覽被繞過(cmd_preview 永遠回 0),讀內容那一段要自己再擋一次。
        real_prev17 = globals()['cmd_preview']
        globals()['cmd_preview'] = lambda *_a, **_k: 0
        try:
            with _Hush():
                cmd_install(g17, mod17, True)
            fails.append('餌17 沒抓到:預覽被繞過的時候,讀內容那一段沒有自己再擋一次')
        except Stop:
            pass
        finally:
            globals()['cmd_preview'] = real_prev17
        if _snap17() != want17:
            fails.append('餌17 沒抓到:第二道擋下來了,遊戲檔卻被動到')
        # 陰性對照:同一包加了 --allow-code 就要裝得起來,不然上面全是「整支壞掉」造成的假綠燈。
        with _Hush():
            rc17b = cmd_install(g17, mod17, True, allow_code=True)
        if rc17b != 0 or _st_read(os.path.join(g17, 'mvp2005.exe')) != EVIL:
            fails.append('餌17 陰性對照:加了 --allow-code 應該裝得起來(rc=%s)' % rc17b)
        # 指令列的旗標真的有一路傳到 cmd_install:沒加就是 1,加了就是 0。
        g17m = _st_game(tmp, 'codemain', {'data/database/attrib.dat': ATT0, 'mvp2005.exe': EXE0})
        real_argv = sys.argv
        try:
            for flag, want_rc in (([], 1), (['--allow-code'], 0)):
                sys.argv = ['mvp_pack.py', g17m, '--install', mod17, '--apply'] + flag
                with _Hush():
                    got_rc = main()
                if got_rc != want_rc:
                    fails.append('餌17 沒抓到:指令列 %s 的結束碼應該是 %d,實際 %s'
                                 % (flag or '(沒加 --allow-code)', want_rc, got_rc))
                if flag == [] and _st_read(os.path.join(g17m, 'mvp2005.exe')) != EXE0:
                    fails.append('餌17 沒抓到:指令列沒加 --allow-code,mvp2005.exe 卻被換掉')
        finally:
            sys.argv = real_argv

        # ── 餌18:副檔名換個寫法也要認得出來 ───────────────────────
        # 大小寫、結尾的空白與點(Windows 會吃掉)、NTFS 的另一條資料流、雙副檔名、
        # 全形、看不見的字元、資料夾分隔符號。每一種都是讓單純比對副檔名失效的寫法。
        for spelled in ('MVP2005.EXE', 'Mvp2005.Exe', 'mvp2005.exe ', 'mvp2005.exe.',
                        'mvp2005.exe . .', 'x.dll:stream', 'x.dll::$DATA', 'C:evil.exe',
                        'readme.txt.exe', 'data\\sub\\x.DLL', 'data/sub/x.dll',
                        'x.dll', 'x.d\u200bll', 'x.dll\u00a0', 'a.asi',
                        'a.bat', 'a.cmd', 'a.com', 'a.ps1', 'a.vbs', 'a.js', 'a.scr', 'a.lnk',
                        # 2026-10-30 複查補進清單的新副檔名,同樣每種寫法都至少用一個新的試過:
                        # 大小寫、結尾空白與點、另一條資料流、雙副檔名、全形、看不見的字元、斜線
                        'A.HTA', 'a.wsf ', 'a.wsh.', 'a.vbe . .', 'a.jse:stream', 'a.url::$DATA',
                        'readme.txt.msi', 'data\\sub\\x.MSP', 'data/sub/x.reg', 'x.jar',
                        'x.appl​ication', 'x.scf ', 'C:evil.appref-ms', 'x.GADGET:abc',
                        'x.pif ', 'x.Cpl.', 'x.OCX', 'a.sys:s', 'readme.txt.inf',
                        'RUN.COMMAND', 'setup.sh ', 'tool.py.', 'x.pkg:abc'):
            if code_ext_of(spelled) is None:
                fails.append('餌18 沒抓到:%r 竟然不算程式' % spelled)
        # 新副檔名逐一試正反兩面(這份名單刻意寫死在這裡,不從 CODE_EXTS 產生:
        # 從 CODE_EXTS 產生的話,拿掉某個副檔名時探針會跟著一起消失,測試就永遠綠)。
        NEW_EXTS = ('.hta', '.wsf', '.wsh', '.vbe', '.jse', '.url', '.scf', '.msi', '.msp',
                    '.reg', '.jar', '.application', '.appref-ms', '.gadget', '.pif', '.cpl',
                    '.ocx', '.sys', '.inf', '.command', '.sh', '.py', '.pkg')
        for e_ in NEW_EXTS:
            for pos in ('a' + e_, 'data/sub/B' + e_.upper(), 'a' + e_ + ' .'):
                if code_ext_of(pos) != e_:
                    fails.append('餌18 沒抓到:%r 應該算程式(%s),實際 %r' % (pos, e_, code_ext_of(pos)))
            for neg in ('a' + e_ + '.txt', 'a' + e_ + 'x', 'a' + e_[1:], e_[1:], 'x' + e_ + '.bak'):
                if code_ext_of(neg) is not None:
                    fails.append('餌18 誤殺:%r 不是程式,卻被當成程式(%s)' % (neg, code_ext_of(neg)))
        # 陰性對照:資料檔不可以被誤殺(包含名字裡剛好有 com / exe / js 字樣的)。
        for plain in ('attrib.dat', 'data/CONFIG.dat', 'x.exe.txt', 'x.dll.bak', 'exe', 'dll',
                      'notes.txt', 'data/台灣之光.dat', '.hidden', 'x.comic',
                      'x.company', 'archive.js.txt', 'a.json', 'x.dllx',
                      'x.info', 'x.system', 'x.shx', 'x.pyc', 'x.commands', 'a.urls',
                      'x.regex', 'x.jarx', 'x.pkgs', 'x.hta.txt', 'x.sh.bak'):
            if code_ext_of(plain) is not None:
                fails.append('餌18 誤殺:%r 不是程式,卻被當成程式' % plain)
        if code_ext_of(None) is not None or code_ext_of(123) is not None:
            fails.append('餌18:不是字串的檔名要回 None,不可以爆掉')
        # 端到端:新增的大寫 DLL、雙副檔名,預覽也要判成不能裝;反過來 x.exe.txt 要放行。
        for odd in ('data/PLUGIN.DLL', 'data/readme.txt.exe',
                    'data/start.HTA', 'data/run.wsf', 'data/sub/setup.MSI', 'data/x.url'):
            g18 = _st_game(tmp, 'odd', {})
            m18 = _st_mod(os.path.join(tmp, 'odd.mvpmod'), [(odd, b'plain bytes')])
            with _Hush():
                if cmd_preview(g18, m18) == 0:
                    fails.append('餌18 沒抓到:%s 在預覽裡竟然過關' % odd)
        g18 = _st_game(tmp, 'odd2', {})
        m18 = _st_mod(os.path.join(tmp, 'odd2.mvpmod'), [('data/readme.exe.txt', b'plain bytes')])
        with _Hush():
            if cmd_preview(g18, m18) != 0:
                fails.append('餌18 誤殺:data/readme.exe.txt 是文字檔,預覽卻不放行')
        # 端到端(真的 --install --apply):文字型的可執行檔(.hta,沒有 MZ 開頭)
        # 沒加 --allow-code 要整包不裝、遊戲檔不動、不留備份;加了才裝得起來。
        # 以前 .hta 不在清單裡,兩道判斷都攔不住,等於沒加旗標就裝進去了。
        HTA = b'<html><script>/* not an MZ file, still runs under mshta */</script></html>'
        g18h = _st_game(tmp, 'hta', {'data/database/attrib.dat': ATT0})
        a18h = os.path.join(g18h, 'data', 'database', 'attrib.dat')
        m18h = _st_mod(os.path.join(tmp, 'hta.mvpmod'),
                       [('data/database/attrib.dat', b'new attrib\n'),
                        ('data/Start.HTA', HTA)])
        with _Hush() as h18h:
            rc18h = cmd_install(g18h, m18h, True)
        if rc18h == 0:
            fails.append('餌18 沒抓到:包裡有 Start.HTA(沒有 MZ 開頭),沒加 --allow-code 竟然回 0')
        if 'Start.HTA' not in h18h.text or '會執行的程式碼' not in h18h.text:
            fails.append('餌18:.hta 命中卻沒有被列出來:%r' % h18h.text[:200])
        if _st_read(a18h) != ATT0 or os.path.exists(os.path.join(g18h, 'data', 'Start.HTA')):
            fails.append('餌18 沒抓到:.hta 被拒絕了,遊戲檔卻被動到')
        left18h = [n for _r, _d, fs in os.walk(g18h) for n in fs
                   if n.endswith(INSTALL_BAK) or n.endswith(BAK_RECEIPT)]
        if left18h:
            fails.append('餌18 沒抓到:.hta 被拒絕了卻留下備份 %s' % left18h)
        with _Hush():
            rc18h2 = cmd_install(g18h, m18h, True, allow_code=True)
        if rc18h2 != 0 or _st_read(os.path.join(g18h, 'data', 'Start.HTA')) != HTA:
            fails.append('餌18 陰性對照:加了 --allow-code 應該裝得起來(rc=%s)' % rc18h2)
        # 陰性對照:名字裡有 hta / wsf 字樣、但副檔名不是的資料檔照裝。
        g18f = _st_game(tmp, 'htafine', {'data/database/attrib.dat': ATT0})
        m18f = _st_mod(os.path.join(tmp, 'htafine.mvpmod'),
                       [('data/database/attrib.dat', b'new attrib\n'),
                        ('data/start.hta.txt', HTA), ('data/run.wsfx', b'<job/>')])
        with _Hush():
            rc18f = cmd_install(g18f, m18f, True)
        if rc18f != 0:
            fails.append('餌18 誤殺:start.hta.txt / run.wsfx 是資料檔,卻被擋下來(rc=%s)' % rc18f)

        # ── 餌19:副檔名看不出來,但內容開頭是 MZ ────────────────────
        # 要「列出命中」而不是安靜地拒絕:讀的人要看得到是哪個檔、為什麼。
        g19 = _st_game(tmp, 'mz', {'data/database/attrib.dat': ATT0,
                                    'data/database/roster.dat': b'old roster\n'})
        a19 = os.path.join(g19, 'data', 'database', 'attrib.dat')
        r19 = os.path.join(g19, 'data', 'database', 'roster.dat')
        mod19 = _st_mod(os.path.join(tmp, 'mz.mvpmod'),
                        [('data/database/attrib.dat', b'new attrib\n'),
                         ('data/database/roster.dat', b'MZ\x90\x00 pretend this is a roster\n')])
        with _Hush() as h19:
            rc19 = cmd_install(g19, mod19, True)
        if rc19 == 0:
            fails.append('餌19 沒抓到:內容開頭是 MZ 的檔,沒加 --allow-code 竟然回 0')
        if 'roster.dat' not in h19.text or 'MZ' not in h19.text or '會執行的程式碼' not in h19.text:
            fails.append('餌19:MZ 命中的檔沒有被列出來(靜默拒絕):%r' % h19.text[:300])
        if _st_read(a19) != ATT0 or _st_read(r19) != b'old roster\n':
            fails.append('餌19 沒抓到:被拒絕了,遊戲檔卻被動到(整包要嘛全裝要嘛全不裝)')
        # 陰性對照:MZ 不在開頭(或只是小寫的 mz)的資料檔照裝。
        for fine in (b'XMZ not at the start\n', b'mz lowercase is not an exe\n', b'M'):
            g19b = _st_game(tmp, 'mzfine', {'data/database/attrib.dat': ATT0})
            m19b = _st_mod(os.path.join(tmp, 'mzfine.mvpmod'), [('data/database/attrib.dat', fine)])
            with _Hush():
                rc19b = cmd_install(g19b, m19b, True)
            if rc19b != 0:
                fails.append('餌19 誤殺:%r 開頭不是 MZ,卻被擋下來(rc=%s)' % (fine[:8], rc19b))
        real_prev19 = globals()['cmd_preview']
        globals()['cmd_preview'] = lambda *_a, **_k: 0
        try:
            with _Hush():
                cmd_install(g19, mod19, True)
            fails.append('餌19 沒抓到:預覽被繞過的時候,讀完內容沒有再看一次 MZ')
        except Stop:
            pass
        finally:
            globals()['cmd_preview'] = real_prev19
        if _st_read(a19) != ATT0 or _st_read(r19) != b'old roster\n':
            fails.append('餌19 沒抓到:第二道擋下來了,遊戲檔卻被動到')
        with _Hush():
            rc19c = cmd_install(g19, mod19, True, allow_code=True)
        if rc19c != 0 or not _st_read(r19).startswith(b'MZ'):
            fails.append('餌19 陰性對照:加了 --allow-code 應該裝得起來(rc=%s)' % rc19c)

        # ── 餌20:壓縮炸彈。預覽要看得出來、安裝要拒絕、而且一個項目都不讀 ──
        # (2026-10-30,稽核 D2-04)manifest 寫 10 bytes、實際是 8 MB 的零(壓縮後約 8 KB)。
        # 舊版預覽印「10 B」,--apply 把整個項目讀進記憶體。這裡的炸彈只有 8 MB,
        # 免得自我測試本身就把讀者的電腦吃光;壓縮比超過 200 倍就夠觸發。
        g20 = _st_game(tmp, 'bomb', {})
        bomb20 = _st_mod_raw(os.path.join(tmp, 'bomb.mvpmod'),
                             {'files': [{'path': 'data/x.dat', 'size': 10}]},
                             [('files/data/x.dat', b'\0' * (8 << 20))], deflate=True)
        opened20 = []
        real_open20 = zipfile.ZipFile.open

        def _spy_open20(self, name, mode='r', pwd=None, **kw):
            opened20.append(name.filename if isinstance(name, zipfile.ZipInfo) else name)
            return real_open20(self, name, mode, pwd, **kw)

        zipfile.ZipFile.open = _spy_open20
        try:
            with _Hush() as h20:
                rc20p = cmd_preview(g20, bomb20)
            with _Hush():
                rc20 = cmd_install(g20, bomb20, True)
        finally:
            zipfile.ZipFile.open = real_open20
        if rc20p == 0 or rc20 == 0:
            fails.append('餌20 沒抓到:壓縮炸彈的預覽 / 安裝竟然回 0(%s / %s)' % (rc20p, rc20))
        if '大小不一致' not in h20.text or '壓縮比' not in h20.text:
            fails.append('餌20:預覽沒有標出大小不一致與壓縮比:%r' % h20.text[:300])
        # 要看「清單那一行」本人顯示的大小,不是整段文字裡哪裡出現過 8.0 MB(問題說明裡也有)。
        if not any(ln.strip().startswith('data/x.dat') and human(8 << 20) in ln
                   for ln in h20.text.split('\n')):
            fails.append('餌20 沒抓到:預覽清單上顯示的不是壓縮檔裡實際的大小(%s)' % human(8 << 20))
        if [n for n in opened20 if n != MANIFEST]:
            fails.append('餌20 沒抓到:大小上限還沒檢查完,就已經打開了項目 %s'
                         % [n for n in opened20 if n != MANIFEST])
        if os.path.lexists(os.path.join(g20, 'data', 'x.dat')):
            fails.append('餌20 沒抓到:被拒絕了,遊戲資料夾裡卻多了 x.dat')
        # 第二道:預覽被繞過(永遠回 0)的時候,讀內容之前要自己再查一次上限。
        opened20c = []
        zipfile.ZipFile.open = (lambda self, name, mode='r', pwd=None, **kw:
                                (opened20c.append(name.filename if isinstance(name, zipfile.ZipInfo)
                                                  else name),
                                 real_open20(self, name, mode, pwd, **kw))[1])
        real_prev20 = globals()['cmd_preview']
        globals()['cmd_preview'] = lambda *_a, **_k: 0
        try:
            with _Hush():
                cmd_install(g20, bomb20, True)
            fails.append('餌20 沒抓到:預覽被繞過的時候,讀內容之前沒有再查一次大小上限')
        except Stop:
            pass
        finally:
            globals()['cmd_preview'] = real_prev20
            zipfile.ZipFile.open = real_open20
        if [n for n in opened20c if n != MANIFEST]:
            fails.append('餌20 沒抓到:第二道上限檢查之前就打開了項目 %s'
                         % [n for n in opened20c if n != MANIFEST])
        # 目錄上的大小說謊(manifest 跟著說一樣的謊,所以大小對得上):
        # 說大了 → 讀出來比宣告的少;說小了 → 讀到宣告的大小就截斷,CRC 對不上。
        # 預覽會在認 MZ 的時候就撞到(說大了撞不到,要到真的讀內容才發現);
        # 預覽被繞過的話,讀內容那一段自己也要擋。兩種都要變成一句人話或拒絕,
        # 而且一個遊戲檔都不可以被動到。
        for lie, label, want_msg in ((2005, '說大了', '不一樣'), (1990, '說小了', '讀不下去')):
            g20c = _st_game(tmp, 'lie', {'data/database/attrib.dat': ATT0})
            m20c = _st_lie_size(
                _st_mod_raw(os.path.join(tmp, 'lie.mvpmod'),
                            {'files': [{'path': 'data/database/attrib.dat', 'size': lie}]},
                            [('files/data/database/attrib.dat', b'z' * 2000)]),
                'files/data/database/attrib.dat', lie)
            att20c = os.path.join(g20c, 'data', 'database', 'attrib.dat')
            try:
                with _Hush():
                    rc20c = cmd_install(g20c, m20c, True)
                if rc20c == 0:
                    fails.append('餌20 沒抓到:目錄上的大小%s,竟然照裝' % label)
            except Stop:
                pass
            except BaseException as e:
                fails.append('餌20 沒抓到:目錄上的大小%s,噴出了原始例外 %r' % (label, e))
            real_prev20b = globals()['cmd_preview']
            globals()['cmd_preview'] = lambda *_a, **_k: 0
            try:
                with _Hush():
                    cmd_install(g20c, m20c, True)
                fails.append('餌20 沒抓到:預覽被繞過、目錄上的大小%s,讀內容那一段竟然照裝' % label)
            except Stop as e:
                if want_msg not in str(e):
                    fails.append('餌20:目錄上的大小%s,訊息應該提到「%s」:%s' % (label, want_msg, e))
            except BaseException as e:
                fails.append('餌20 沒抓到:預覽被繞過、目錄上的大小%s,噴出了原始例外 %r' % (label, e))
            finally:
                globals()['cmd_preview'] = real_prev20b
            if _st_read(att20c) != ATT0:
                fails.append('餌20 沒抓到:目錄上的大小%s,遊戲檔卻被動到' % label)
        # 陰性對照:一包正常的(餌17 那種)安裝時一定會打開項目,不然上面的偵測器是瞎的。
        opened20b = []
        zipfile.ZipFile.open = (lambda self, name, mode='r', pwd=None, **kw:
                                (opened20b.append(name.filename if isinstance(name, zipfile.ZipInfo)
                                                  else name),
                                 real_open20(self, name, mode, pwd, **kw))[1])
        try:
            g20b = _st_game(tmp, 'bombok', {'data/database/attrib.dat': ATT0})
            m20b = _st_mod(os.path.join(tmp, 'bombok.mvpmod'),
                           [('data/database/attrib.dat', b'new attrib\n')])
            with _Hush():
                rc20b = cmd_install(g20b, m20b, True)
        finally:
            zipfile.ZipFile.open = real_open20
        if rc20b != 0 or not [n for n in opened20b if n != MANIFEST]:
            fails.append('餌20 陰性對照:正常的包安裝時偵測器沒看到項目被打開(rc=%s)' % rc20b)

        # ── 餌21:單項、整包、manifest 三個上限 ─────────────────────
        # 用改小的上限測(真的 1 GB / 4 GB 的包不可能放進自我測試),每個都配一個放行的對照。
        g21 = _st_game(tmp, 'caps', {})
        mod21 = _st_mod(os.path.join(tmp, 'caps.mvpmod'),
                        [('data/a.dat', bytes(range(256)) * 8), ('data/b.dat', bytes(range(256)) * 8)])
        for const, small, needle in (('MAX_ENTRY_BYTES', 1500, '單項解開後'),
                                     ('MAX_TOTAL_BYTES', 3000, '整包解開後合計')):
            real_c = globals()[const]
            globals()[const] = small
            try:
                with _Hush() as h21:
                    rc21 = cmd_install(g21, mod21, True)
            finally:
                globals()[const] = real_c
            if rc21 == 0 or needle not in h21.text:
                fails.append('餌21 沒抓到:%s 調小之後超過上限竟然放行(rc=%s)' % (const, rc21))
            if os.path.lexists(os.path.join(g21, 'data', 'a.dat')):
                fails.append('餌21 沒抓到:%s 擋下來了,a.dat 卻被寫進去' % const)
        with _Hush():
            rc21ok = cmd_preview(g21, mod21)
        if rc21ok != 0:
            fails.append('餌21 陰性對照:沒超過上限的包被擋下來了(rc=%s)' % rc21ok)
        real_mm = globals()['MAX_MANIFEST_BYTES']
        globals()['MAX_MANIFEST_BYTES'] = 20
        try:
            read_manifest(mod21)
            fails.append('餌21 沒抓到:manifest.json 超過上限竟然照讀')
        except Stop as e:
            if 'manifest.json' not in str(e):
                fails.append('餌21:manifest 超過上限的訊息沒有提到 manifest.json(%s)' % e)
        finally:
            globals()['MAX_MANIFEST_BYTES'] = real_mm
        try:
            read_manifest(mod21)
        except Stop as e:
            fails.append('餌21 陰性對照:正常大小的 manifest.json 被擋下來了(%s)' % e)

        # ── 餌22:manifest 寫的 size 跟包裡實際的不一樣,或根本不是數字 ──
        g22 = _st_game(tmp, 'sizes', {})
        blob22 = b'x' * 2000

        def _mod22(size_field):
            f = {'path': 'data/x.dat', 'sha256': hashlib.sha256(blob22).hexdigest()}
            if size_field is not _MISSING:
                f['size'] = size_field
            return _st_mod_raw(os.path.join(tmp, 'sizes.mvpmod'),
                               {'files': [f]}, [('files/data/x.dat', blob22)])

        for bad22, needle in ((10, '大小不一致'), (2001, '大小不一致'), ('abc', '不是數字'),
                              (None, '不是數字'), (True, '不是數字'), (-5, '不是數字'),
                              (1.5, '不是數字'), ([1], '不是數字')):
            m22 = _mod22(bad22)
            try:
                with _Hush() as h22:
                    rc22 = cmd_install(g22, m22, True)
            except BaseException as e:
                fails.append('餌22 沒抓到:size=%r 噴出了例外 %r(應該印 🔴 一句人話)' % (bad22, e))
                continue
            if rc22 == 0 or needle not in h22.text:
                fails.append('餌22 沒抓到:size=%r 竟然放行(rc=%s)' % (bad22, rc22))
            if os.path.lexists(os.path.join(g22, 'data', 'x.dat')):
                fails.append('餌22 沒抓到:size=%r 被擋下來了,x.dat 卻被寫進去' % (bad22,))
        for good22 in (2000, _MISSING):
            m22 = _mod22(good22)
            with _Hush():
                rc22 = cmd_preview(g22, m22)
            if rc22 != 0:
                fails.append('餌22 陰性對照:size=%r 被擋下來了(rc=%s)' % (good22, rc22))

        # ── 餌23:不可信的字串不可以把控制字元帶到畫面上 ────────────────
        # (2026-10-30,稽核 D2-05)ESC 序列可以把游標往上移、清掉整行,再印一行假的 ✅。
        def _ctrl_in(text):
            """畫面文字裡還剩下的「該被洗掉」的控制字元(換行是 print 自己加的,不算)。"""
            return sorted(set(c for c in text if c not in '\t\n'
                              and unicodedata.category(c) in _CTRL_CATS))

        FAKE = '\x1b[2K\x1b[1A  ✅ 每一個檔的路徑都在遊戲資料夾裡面'
        meta23 = {'name': 'n\x1b[31mame', 'author': 'a\x1b]52;c;ZXZpbA==\x07b',
                  'version': '1\x9b2\u202ex',
                  'note': 'line1\r\n  ✅ FAKE-OK-LINE\n' + FAKE}
        g23 = _st_game(tmp, 'ctrl', {})
        files23 = [{'path': 'data/ok.dat', 'size': 3}, {'path': 'data/a\x1b[2Kb.dat', 'size': 3},
                   {'path': 'data/\u202eexe.dat', 'size': 3}]
        m23 = _st_mod_raw(os.path.join(tmp, 'ctrl.mvpmod'), dict(meta23, files=files23),
                          [('files/data/ok.dat', b'abc'), ('files/data/a\x1b[2Kb.dat', b'abc'),
                           ('files/data/\u202eexe.dat', b'abc')])
        with _Hush() as h23:
            rc23 = cmd_install(g23, m23, True)
        if rc23 == 0:
            fails.append('餌23 沒抓到:路徑含 ESC 與 U+202E 的檔竟然放行')
        if _ctrl_in(h23.text):
            fails.append('餌23 沒抓到:畫面上還剩控制字元 %r' % _ctrl_in(h23.text))
        if '\\x1b' not in h23.text:
            fails.append('餌23:ESC 沒有換成看得見的 \\x1b(%r)' % h23.text[:200])
        if any(ln.startswith('  ✅') for ln in h23.text.split('\n')):
            fails.append('餌23 沒抓到:說明欄的換行讓假的 ✅ 單獨成了一行')
        for bad_rel in ('data/a\x1bb', 'data/a\tb', 'data/a\x7fb', 'data/a\x9bb', 'data/a\u202eb',
                        'data/a\u2028b'):
            if is_safe_relpath(bad_rel):
                fails.append('餌23 沒抓到:路徑 %r 含控制字元卻算安全' % bad_rel)
        if not is_safe_relpath('data/台灣之光.dat'):
            fails.append('餌23 誤殺:正常的中文路徑被控制字元那一道擋掉了')
        # 錯誤訊息那條路也要洗:一個名字帶 ESC 的 .mvpmod(不存在,免得 Windows 建不了這種檔名)。
        try:
            cmd_preview(g23, 'no\x1b[2Jsuch.mvpmod')
            fails.append('餌23:不存在的包竟然沒有報錯')
        except Stop as e:
            if _ctrl_in(str(e)):
                fails.append('餌23 沒抓到:錯誤訊息裡還剩控制字元 %r' % _ctrl_in(str(e)))
        # 最外層出口用的設定:多行訊息的換行要留著,ESC 要洗掉。
        kept = clean_text('a\x1b[2Kb\nline2\tx', 4000, keep='\t\n')
        if '\x1b' in kept or '\n' not in kept or '\t' not in kept:
            fails.append('餌23:keep 參數沒照設計運作(%r)' % kept)
        # 陰性對照:正常的字串(中文、Tab)一個字都不變;太長的會被截斷。
        if clean_text('台灣之光\t2026') != '台灣之光\t2026':
            fails.append('餌23 誤殺:正常的中文字串被改掉了')
        if len(clean_text('x' * 1000, 50)) > 80:
            fails.append('餌23 沒抓到:太長的字串沒有被截斷')
        if clean_text(['a\x1b']) != repr(['a\x1b']):
            fails.append('餌23:不是字串的欄位要先轉成 repr')

        # ── 餌24:同一個檔旁邊有好幾份備份,要跟「每一份」比,而且跟檔名順序無關 ──
        # (2026-10-30,稽核覆核發現)舊版先看到哪一份備份就只比那一份:
        # 檔名字典順序 .bak 排在 .stancebak 前面,所以現在的檔跟舊 .bak 一樣、
        # 跟專屬 .stancebak 不一樣時,被判成「等於沒改」,--build 靜默漏包。
        A24 = 'data/database/attrib.dat'
        X24, Y24, Z24 = b'X current\n', b'Y other\n', b'Z another\n'
        n24 = [0]

        def _scan24(cur, baks):
            """造一個假遊戲資料夾(attrib.dat + 指定的備份),回傳 (資料夾, 掃到的那一筆)。"""
            n24[0] += 1
            ents = {A24: cur}
            for suf, blob in baks:
                ents[A24 + suf] = blob
            gg = _st_game(tmp, 'multi%d' % n24[0], ents)
            hits = [i for i in find_changed(gg) if i['rel'] == A24]
            return gg, (hits[0] if len(hits) == 1 else hits)

        # (標籤, 現在的檔, 備份們, 預期的 same, 預期要印的那個備份名)
        for lab24, cur24, baks24, want_same, want_bak in (
                ('(a)只有 .stancebak、一樣', X24, [('.stancebak', X24)], True, '.stancebak'),
                ('(a)只有 .stancebak、不同', X24, [('.stancebak', Y24)], False, '.stancebak'),
                ('(b)只有舊 .bak、一樣', X24, [('.bak', X24)], True, '.bak'),
                ('(b)只有舊 .bak、不同', X24, [('.bak', Y24)], False, '.bak'),
                ('(c)兩份都有、內容相同、現在的檔也一樣', X24,
                 [('.bak', X24), ('.stancebak', X24)], True, '.stancebak'),
                ('(c)兩份都有、內容相同、現在的檔不同', Z24,
                 [('.bak', X24), ('.stancebak', X24)], False, '.stancebak'),
                ('(d)兩份互異、現在的檔=舊 .bak', X24,
                 [('.bak', X24), ('.stancebak', Y24)], False, '.stancebak'),
                ('(d)兩份互異、現在的檔=.stancebak', X24,
                 [('.bak', Y24), ('.stancebak', X24)], False, '.stancebak'),
                ('(d)兩份互異、現在的檔兩份都不是', Z24,
                 [('.bak', X24), ('.stancebak', Y24)], False, '.stancebak'),
                ('(e)排在 .bak 前面的專屬備份、現在的檔=.bak', X24,
                 [('.audiobak', Y24), ('.bak', X24)], False, '.audiobak'),
                ('(e)排在 .bak 前面的專屬備份、現在的檔=專屬', X24,
                 [('.audiobak', X24), ('.bak', Y24)], False, '.audiobak'),
                ('(e)三份都一樣', X24,
                 [('.audiobak', X24), ('.bak', X24), ('.stancebak', X24)], True, '.audiobak'),
                ('(e)三份裡只有一份不同', X24,
                 [('.audiobak', X24), ('.bak', X24), ('.stancebak', Y24)], False, '.audiobak')):
            _g24, it24 = _scan24(cur24, baks24)
            if not isinstance(it24, dict):
                fails.append('餌24 %s:該掃到恰好一筆 attrib.dat,實際是 %r' % (lab24, it24))
                continue
            if it24['same'] != want_same:
                fails.append('餌24 沒抓到 %s:same 應該是 %s,實際是 %s'
                             % (lab24, want_same, it24['same']))
            if it24['bak'] != want_bak:
                fails.append('餌24 沒抓到 %s:專屬備份應該優先於通用 .bak,印出來的是 %s(應該是 %s)'
                             % (lab24, it24['bak'], want_bak))
            if sorted(s for s, _ok in it24['baks']) != sorted(s for s, _b in baks24):
                fails.append('餌24 %s:baks 欄位沒有記到每一份備份(%r)' % (lab24, it24['baks']))
        # 有互異備份的檔,--build 一定要收進包。d1(現在的檔=舊 .bak)是舊版真正會靜默漏包的
        # 那一種:舊版只比檔名排最前面的 .bak,判成「等於沒改」;d2 是反過來(現在的檔=
        # .stancebak),舊版會多收一個其實已經還原的檔。兩種都要收 —— 只有跟「每一份」
        # 都一樣的 c 才可以略過(那一組沒有別的檔,所以是「沒有東西可以打包」)。
        g24 = {}
        for tag24, baks24 in (('d1', [('.bak', X24), ('.stancebak', Y24)]),
                              ('d2', [('.bak', Y24), ('.stancebak', X24)]),
                              ('c', [('.bak', X24), ('.stancebak', X24)])):
            g24[tag24] = _scan24(X24, baks24)[0]
        for tag24 in ('d1', 'd2'):
            out24 = os.path.join(tmp, 'multi_%s.mvpmod' % tag24)
            try:
                with _Hush():
                    cmd_build(g24[tag24], out24, 'n', 'a', '1.0', '', [])
                with zipfile.ZipFile(out24) as z24:
                    if 'files/' + A24 not in z24.namelist():
                        fails.append('餌24 沒抓到(%s):跟其中一份備份不同的檔,--build 竟然沒收進包'
                                     % tag24)
            except Stop as e:
                fails.append('餌24 沒抓到(%s):跟其中一份備份不同的檔,--build 卻說沒東西可打包'
                             '(靜默漏包)(%s)' % (tag24, e))
        try:
            with _Hush():
                cmd_build(g24['c'], os.path.join(tmp, 'multi_c.mvpmod'), 'n', 'a', '1.0', '', [])
            fails.append('餌24 誤殺:每一份備份都跟現在的檔一樣,卻還是收進包')
        except Stop as e:
            if '沒有東西可以打包' not in str(e):
                fails.append('餌24 陰性對照:每一份都一樣,停下來的理由卻不對(%s)' % e)
        # 畫面要講清楚是跟哪一份相同、哪一份不同;全部一樣時才可以印「等於沒改」。
        for tag24, same_with, diff_with in (('d1', '.bak', '.stancebak'),
                                            ('d2', '.stancebak', '.bak')):
            with _Hush() as h24:
                cmd_scan(g24[tag24])
            if ('跟 %s 一樣' % same_with not in h24.text
                    or '跟 %s 不一樣' % diff_with not in h24.text):
                fails.append('餌24 沒抓到(%s):兩份備份互異時,畫面沒講清楚跟 %s 相同、跟 %s 不同(%r)'
                             % (tag24, same_with, diff_with, h24.text[:300]))
            if '等於沒改' in h24.text:
                fails.append('餌24 沒抓到(%s):跟其中一份不同的檔,畫面竟然說它「等於沒改」' % tag24)
        with _Hush() as h24c:
            cmd_scan(g24['c'])
        if '等於沒改' not in h24c.text or '跟每一份都一樣' not in h24c.text:
            fails.append('餌24 陰性對照:每一份備份都一樣,畫面卻沒有說「等於沒改」(%r)'
                         % h24c.text[:300])
        # 順序無關:把檔名順序反過來、BAK_SUFFIXES 的排列也反過來,結果要一模一樣。
        real_walk24, real_suf24 = os.walk, BAK_SUFFIXES

        def _view24(gg):
            return [(i['rel'], i['bak'], i['same'], sorted(i['baks'])) for i in find_changed(gg)]
        want24 = dict((k, _view24(g)) for k, g in g24.items())

        def _walk24(rev):
            def _w(top, *a, **k):
                for dp, dn, fn in real_walk24(top, *a, **k):
                    yield dp, dn, sorted(fn, reverse=rev)
            return _w
        try:
            for rev24 in (False, True):
                os.walk = _walk24(rev24)
                globals()['BAK_SUFFIXES'] = real_suf24[::-1] if rev24 else real_suf24
                for tag24, want in want24.items():
                    got = _view24(g24[tag24])
                    if got != want:
                        fails.append('餌24 沒抓到(%s):檔名順序%s、BAK_SUFFIXES 順序%s,結果跟原本不一樣'
                                     ' —— 判斷不可以取決於順序(%r 對 %r)'
                                     % (tag24, '反過來' if rev24 else '照字典',
                                        '反過來' if rev24 else '照原樣', got, want))
        finally:
            os.walk = real_walk24
            globals()['BAK_SUFFIXES'] = real_suf24

        # ── 餌25:預覽之後,包被換成壓縮炸彈(檢查後替換,複查指出的)──
        # 舊寫法:--preview 開一次壓縮檔(讀 manifest、查過大小上限),讀內容那一段又依路徑
        # 重新開一次、沒有再查上限。兩次之間這個檔被換成高壓縮比的包,第二次開到的炸彈
        # 就被整個解壓(CPU / 磁碟被拖住),而且清單上的大小還是第一次留下來的。
        # 這裡用替身模擬:把 zipfile.ZipFile 換成「第一次建好之後,立刻把路徑上的檔換成炸彈」
        # 的子類別,同時記下有沒有任何目錄宣告超過 1 MB 的項目被打開。
        # 現在只開一次、用開著的檔案身分讀,路徑被換了也不影響這一輪。
        g25 = _st_game(tmp, 'swap25', {'data/x.dll': b'old dll\n'})
        legit25 = b'legit dll bytes\n'
        target25 = os.path.join(tmp, 'swap25.mvpmod')
        _st_mod(target25, [('data/x.dll', legit25)])
        # 炸彈包的 manifest 多列一個 data/y.dat:要是 --install 的哪一步又依路徑重新讀了一次
        # manifest(拿到炸彈包的清單),預覽時看過的那一包就對不上,會停下來而不是照裝。
        bomb25 = _st_mod_raw(os.path.join(tmp, 'swap25_bomb.mvpmod'),
                             {'files': [{'path': 'data/x.dll', 'size': len(legit25)},
                                        {'path': 'data/y.dat', 'size': 3}]},
                             [('files/data/x.dll', b'\0' * (8 << 20)),
                              ('files/data/y.dat', b'yyy')], deflate=True)
        bomb25_bytes = _st_read(bomb25)
        real_zip25 = zipfile.ZipFile
        built25 = [0]
        big25 = []
        swapped25 = []

        class _SwapZip(real_zip25):
            def __init__(self, *a, **k):
                real_zip25.__init__(self, *a, **k)
                built25[0] += 1
                if built25[0] == 1 and not swapped25:
                    src = os.path.join(tmp, 'swap25_src.mvpmod')
                    with open(src, 'wb') as fh_:
                        fh_.write(bomb25_bytes)
                    try:
                        os.replace(src, target25)       # 路徑換成炸彈(舊的 inode 還被我們開著)
                        swapped25.append(True)
                    except OSError as e_:
                        swapped25.append(e_)            # Windows:開著的檔換不掉

            def open(self, name, mode='r', pwd=None, **kw):
                zi_ = name if isinstance(name, zipfile.ZipInfo) else self.getinfo(name)
                if zi_.file_size > (1 << 20):
                    big25.append(zi_.filename)
                return real_zip25.open(self, name, mode, pwd, **kw)

        zipfile.ZipFile = _SwapZip
        try:
            with _Hush():
                try:
                    rc25 = cmd_install(g25, target25, True, allow_code=True)
                    err25 = None
                except Stop as e:
                    rc25, err25 = None, e
        finally:
            zipfile.ZipFile = real_zip25
        if swapped25 and swapped25[0] is not True:
            skips.append(('餌25', '這台機器的檔案正開著的時候換不掉它(%s),模擬不了這一種' % swapped25[0]))
        else:
            if not swapped25:
                fails.append('餌25:替身沒有真的把路徑換成炸彈(測試本身壞了)')
            elif _st_read(target25) != bomb25_bytes:
                fails.append('餌25:路徑上的檔不是炸彈(測試本身壞了)')
            if big25:
                fails.append('餌25 沒抓到:預覽之後包被換成炸彈,目錄宣告超過 1 MB 的項目%s被打開了'
                             % big25)
            x25 = os.path.join(g25, 'data', 'x.dll')
            # 只開一次的安裝,照預覽時看過的那一包裝完(不是拒絕,也不是裝到別的東西)。
            if err25 is not None or rc25 != 0:
                fails.append('餌25:路徑被換成炸彈,只開一次的安裝應該照預覽時看過的那一包裝完(rc=%s, %s)'
                             % (rc25, err25))
            elif _st_read(x25) != legit25:
                fails.append('餌25:回 0 了,裝進去的卻不是預覽時看到的那一包(%r)' % _st_read(x25)[:40])
            # 陰性對照:換成炸彈之後,真的依路徑重新開、再打開項目,偵測器一定要看得到。
            # 偵測器是瞎的,上面那些「沒有被打開」就沒有意義。
            del big25[:]
            zipfile.ZipFile = _SwapZip
            try:
                with zipfile.ZipFile(target25) as z25:
                    z25.open('files/data/x.dll').read(1)
            finally:
                zipfile.ZipFile = real_zip25
            if big25 != ['files/data/x.dll']:
                fails.append('餌25 陰性對照:換成炸彈之後重新打開,偵測器沒看到項目被打開(%r)' % big25)

        # ── 餌26:預覽畫面上列的 SHA-256,要等於裝進去的那一份 ──────────
        # --allow-code 放行的是「畫面上核對過的那一份」。預覽被繞過或換了版本之後,
        # 讀到的位元組算出來的雜湊跟預覽時記下的不一樣,就整包不裝。
        # 用替身:預覽(永遠回 0)順手把一個錯的 SHA-256 記進 shas,模擬「預覽時看的不是這一份」。
        g26 = _st_game(tmp, 'sha26', {'mvp2005.exe': EXE0})
        m26 = _st_mod(os.path.join(tmp, 'sha26.mvpmod'),
                      [('mvp2005.exe', EVIL), ('data/notes.dat', b'plain data\n')])
        # 真的預覽要把程式碼項目的 SHA-256 記進 shas(資料檔不記),不然下面的核對是空轉。
        shas26 = {}
        with _Hush():
            cmd_preview(g26, m26, True, shas=shas26)
        if shas26 != {'mvp2005.exe': hashlib.sha256(EVIL).hexdigest()}:
            fails.append('餌26 沒抓到:預覽沒有把程式碼項目的 SHA-256 記進 shas(%r)' % shas26)
        real_prev26 = globals()['cmd_preview']
        for label26, shown26 in (('預覽列的是別的內容', '0' * 64),
                                 ('預覽列的是原本的內容', hashlib.sha256(EVIL).hexdigest())):
            globals()['cmd_preview'] = (
                lambda *_a, shown=shown26, **k: (k['shas'].update({'mvp2005.exe': shown}), 0)[1])
            try:
                with _Hush():
                    try:
                        rc26 = cmd_install(g26, m26, True, allow_code=True)
                        err26 = None
                    except Stop as e:
                        rc26, err26 = None, e
            finally:
                globals()['cmd_preview'] = real_prev26
            if shown26 == '0' * 64:
                if rc26 == 0 or err26 is None or 'SHA-256' not in str(err26):
                    fails.append('餌26 沒抓到:%s,裝進去的卻不是預覽列出 SHA-256 的那一份(rc=%s, %s)'
                                 % (label26, rc26, err26))
                if _st_read(os.path.join(g26, 'mvp2005.exe')) != EXE0:
                    fails.append('餌26 沒抓到:SHA-256 對不上被拒絕了,遊戲檔卻被動到')
            elif rc26 != 0 or _st_read(os.path.join(g26, 'mvp2005.exe')) != EVIL:
                fails.append('餌26 陰性對照:%s,應該照裝(rc=%s, %s)' % (label26, rc26, err26))

        # ── 餌27:EA 原廠 webhelp.jar 的例外 ────────────────────────────
        # .jar 在清單裡,所以遊戲自己的 webhelp.jar 以前被判成程式碼。例外要「路徑最後一段」與
        # 「整個檔的 SHA-256」兩件事同時成立。自我測試不帶任何 EA 的檔,所以把 KNOWN_EA_FILES
        # 暫時換成一筆「假原廠檔」的雜湊(測完換回來),走的是跟真的一樣的判斷。
        FAKE_JAR = b'PK\x03\x04 pretend this is the EA original webhelp.jar'
        OLD_JAR = b'PK\x03\x04 the jar that is already in the game'
        FAKE_SHA = hashlib.sha256(FAKE_JAR).hexdigest()
        JAR_REL = 'support/EA Help/webhelp.jar'
        real_known27 = globals()['KNOWN_EA_FILES']
        globals()['KNOWN_EA_FILES'] = (('webhelp.jar', FAKE_SHA, '自我測試的假原廠檔'),)
        try:
            def _game27(tag, jar_name=JAR_REL):
                return _st_game(tmp, tag, {'data/database/attrib.dat': ATT0, jar_name: OLD_JAR})

            def _jar27(g, jar_name=JAR_REL):
                return _st_read(os.path.join(g, *jar_name.split('/')))

            def _att27(g):
                return _st_read(os.path.join(g, 'data', 'database', 'attrib.dat'))
            # 原廠檔放行(預覽與真的安裝都一樣),畫面照列一行名稱與 SHA-256。
            mod27 = _st_mod(os.path.join(tmp, 'jar27.mvpmod'),
                            [('data/database/attrib.dat', b'new attrib\n'), (JAR_REL, FAKE_JAR)])
            g27p = _game27('jar27p')
            with _Hush() as h27p:
                rc27p = cmd_install(g27p, mod27, False)
            if rc27p != 0:
                fails.append('餌27 誤殺:包裡是原廠的 webhelp.jar,預覽不該被當成程式碼擋下(rc=%s)\n%s'
                             % (rc27p, h27p.text))
            if '已知的 EA 原廠檔,略過' not in h27p.text or FAKE_SHA not in h27p.text \
                    or 'webhelp.jar' not in h27p.text:
                fails.append('餌27 沒抓到:預覽沒有把「已知的 EA 原廠檔,略過」與名稱、SHA-256 列在畫面上:%r'
                             % h27p.text[:300])
            if '會執行的程式碼' in h27p.text:
                fails.append('餌27 誤殺:原廠檔還是被列進「會執行的程式碼」:%r' % h27p.text[:300])
            if _jar27(g27p) != OLD_JAR or _att27(g27p) != ATT0:
                fails.append('餌27:預覽動到了遊戲檔')
            g27a = _game27('jar27a')
            with _Hush() as h27a:
                rc27a = cmd_install(g27a, mod27, True)
            if rc27a != 0 or _jar27(g27a) != FAKE_JAR or _att27(g27a) != b'new attrib\n':
                fails.append('餌27 誤殺:原廠的 webhelp.jar 沒加 --allow-code 應該裝得進去(rc=%s)\n%s'
                             % (rc27a, h27a.text))
            # 預覽有把原廠檔的 SHA-256 記進 shas(--install 讀內容時要核對同一份)。
            shas27 = {}
            with _Hush():
                cmd_preview(g27p, mod27, False, shas=shas27)
            if shas27 != {JAR_REL: FAKE_SHA}:
                fails.append('餌27 沒抓到:預覽沒有把原廠檔的 SHA-256 記進 shas(%r)' % shas27)

            def _refused27(tag, entries, why, jar_name=JAR_REL):
                m = _st_mod(os.path.join(tmp, tag + '.mvpmod'), entries)
                g = _game27(tag, jar_name)
                with _Hush() as h:
                    rc = cmd_install(g, m, True)
                if rc == 0:
                    fails.append('餌27 沒抓到:%s,竟然被放行' % why)
                if '會執行的程式碼' not in h.text or '已知的 EA 原廠檔' in h.text:
                    fails.append('餌27:%s,沒有列在「會執行的程式碼」或是誤列成已知的原廠檔:%r'
                                 % (why, h.text[:300]))
                if _jar27(g, jar_name) != OLD_JAR or _att27(g) != ATT0:
                    fails.append('餌27 沒抓到:%s,被拒絕了卻動到遊戲檔' % why)
                if [n for _r, _d, fs in os.walk(g) for n in fs
                        if n.endswith(INSTALL_BAK) or n.endswith(BAK_RECEIPT)]:
                    fails.append('餌27 沒抓到:%s,被拒絕了卻留下備份' % why)
            # 同名但內容不同 —— 只憑檔名的話,任何人都能把惡意的 jar 取名 webhelp.jar 繞過去。
            _refused27('jar27b', [('data/database/attrib.dat', b'new attrib\n'),
                                  (JAR_REL, b'PK\x03\x04 some other jar, same name')],
                       '同名 webhelp.jar 但內容不是原廠的')
            # 同內容但改了名字
            _refused27('jar27c', [('data/database/attrib.dat', b'new attrib\n'),
                                  ('support/EA Help/helper.jar', FAKE_JAR)],
                       '內容跟原廠一樣但改名成 helper.jar', jar_name='support/EA Help/helper.jar')
            # 預覽被繞過(cmd_preview 永遠回 0):讀內容那一段自己再擋一次,同名不同內容照樣 Stop。
            m27x = _st_mod(os.path.join(tmp, 'jar27x.mvpmod'),
                           [(JAR_REL, b'PK\x03\x04 some other jar, same name')])
            g27x = _game27('jar27x')
            real_prev27 = globals()['cmd_preview']
            globals()['cmd_preview'] = lambda *_a, **_k: 0
            try:
                with _Hush():
                    try:
                        cmd_install(g27x, m27x, True)
                        fails.append('餌27 沒抓到:預覽被繞過的時候,同名不同內容的 webhelp.jar 竟然照裝')
                    except Stop:
                        pass
                # 預覽被繞過,但包裡真的是原廠檔:讀內容那一段也要放行(陰性對照,證明上面的 Stop 是內容造成的)
                with _Hush():
                    try:
                        rc27y = cmd_install(_game27('jar27y'), mod27, True)
                    except Stop as e:
                        rc27y = e
                if rc27y != 0:
                    fails.append('餌27 誤殺:預覽被繞過、包裡是原廠檔,讀內容那一段不該擋(%s)' % (rc27y,))
            finally:
                globals()['cmd_preview'] = real_prev27
            if _jar27(g27x) != OLD_JAR:
                fails.append('餌27 沒抓到:第二道擋下來了,遊戲檔卻被動到')
            # 預覽畫面上列的 SHA-256 跟讀到的內容對不上(預覽之後被換成別的內容):整包不裝,遊戲檔不動。
            g27w = _game27('jar27w')
            globals()['cmd_preview'] = (
                lambda *_a, **k: (k['shas'].update({JAR_REL: '0' * 64}), 0)[1])
            try:
                with _Hush():
                    try:
                        rc27w = cmd_install(g27w, mod27, True)
                        err27w = None
                    except Stop as e:
                        rc27w, err27w = None, e
            finally:
                globals()['cmd_preview'] = real_prev27
            if rc27w == 0 or err27w is None or 'SHA-256' not in str(err27w):
                fails.append('餌27 沒抓到:原廠檔的內容跟預覽記下的 SHA-256 對不上,竟然照裝(%s, %s)'
                             % (rc27w, err27w))
            if _jar27(g27w) != OLD_JAR or _att27(g27w) != ATT0:
                fails.append('餌27 沒抓到:SHA-256 對不上被拒絕了,遊戲檔卻被動到')
            # 路徑的寫法變體:大小寫、兩種斜線、全形放行;結尾多了空白 / 點 / 資料流的不放行。
            for ok_name in ('webhelp.jar', 'WEBHELP.JAR', 'WebHelp.Jar', 'support/EA Help/webhelp.jar',
                            'Support\\European Help Files\\WEBHELP.JAR', 'a/b/c/webhelp.jar',
                            'webhelp.jar', 'x/web​help.jar'):
                if known_ea_file(ok_name, FAKE_SHA) is None:
                    fails.append('餌27 單元:%r 的最後一段是 webhelp.jar、內容也對,應該放行' % ok_name)
            for bad_name in ('webhelp.jar ', 'webhelp.jar.', 'webhelp.jar:stream', 'webhelp.jar::$DATA',
                             'webhelp.jar.txt', 'webhelp.jarx', 'xwebhelp.jar', 'my webhelp.jar',
                             'webhelp.jar/readme.txt', 'webhelp.jar\\x.dll', 'webhelp.jar.exe',
                             'webhelp.ja', 'webhelp', '.jar', '', None, 123):
                if known_ea_file(bad_name, FAKE_SHA) is not None:
                    fails.append('餌27 單元:%r 不是原廠的檔名,卻被放行' % (bad_name,))
            # 雜湊要整個對上:少一個字、多一個字、別的內容、不是字串,都不放行。
            for bad_sha in (FAKE_SHA[:-1], FAKE_SHA + '0',
                            FAKE_SHA[:-1] + ('0' if FAKE_SHA[-1] != '0' else '1'),
                            hashlib.sha256(OLD_JAR).hexdigest(), '', None, 0):
                if known_ea_file(JAR_REL, bad_sha) is not None:
                    fails.append('餌27 單元:雜湊 %r 不是原廠那一份,卻被放行' % (bad_sha,))
        finally:
            globals()['KNOWN_EA_FILES'] = real_known27
        # 換回來之後,真正的清單不會放行假原廠檔(證明上面的放行是暫時換進去的那一筆造成的)。
        if known_ea_file(JAR_REL, FAKE_SHA) is not None:
            fails.append('餌27:測試結束後 KNOWN_EA_FILES 沒有換回來,或真正的清單放行了假原廠檔')

        # ── 餌 14:餌數不可以多報 ────────────────────────────────
        # 這一支腳本會下到讀者手上,而 Python 3.7 以下跑不了餌11 / 餌12。
        # 那條跳過的路我這台機器走不到(3.9.6),所以改成直接測算數的那個
        # 函式:一則訊息代表兩個餌時要扣兩個,不是扣一則。
        # (2026-09-06 之前扣的是訊息數,模擬 3.7 實測印「餌 14 項」而
        #  真的跑過的只有 13 個 —— 多報一個。)
        if _bait_tally([]) != len(BAIT_NAMES):
            fails.append('餌14 沒抓到:一個都沒跳過,算出來卻不是 %d'
                         % len(BAIT_NAMES))
        if _bait_tally(['餌11', '餌12']) != len(BAIT_NAMES) - 2:
            fails.append('餌14 沒抓到:跳過兩個餌卻沒有扣掉兩個')
        try:
            _bait_tally(['餌11', '餌11'])
            fails.append('餌14 沒抓到:同一個餌記了兩次竟然照算')
        except Stop:
            pass
        try:
            _bait_tally(['餌沒這個'])
            fails.append('餌14 沒抓到:名單上沒有的餌名竟然照扣')
        except Stop:
            pass
    finally:
        shutil.rmtree(tmp, ignore_errors=True)

    print()
    if fails:
        print('  🔴 自我測試沒過,%d 項:' % len(fails))
        for f in fails:
            print('      %s' % f)
        print()
        return 1
    # 餌一共幾個看 BAIT_NAMES(那裡是唯一的真相來源);跳過的不算跑過,
    # 照**餌名**從總數扣掉,不可以拿「全綠」蓋過去。
    print('  ✅ 自我測試全綠:陰性對照 3 項 + 餌 %d 項,'
          '每一個餌都擋下了它該擋的東西(不是每一道守門都有餌,見檔頭 --selftest 那一段)。'
          % _bait_tally([name for name, _why in skips]))
    for name, why in skips:
        print('  ⏭ 跳過:%s —— %s' % (name, why))
    print('     (在系統暫存區造假的遊戲資料夾跑的,跑完就刪,沒有碰任何真的遊戲檔。)')
    print()
    return 0


def main():
    """指令列入口。回傳值就是行程的結束碼。

    五個動作(scan / build / preview / install / restore)共用同一個前置檢查:
    第一個參數必須是遊戲資料夾。路徑指錯是這一課最常見的卡關點,
    所以寧可在第一行就講清楚,也不要等到後面才冒出一句看不懂的錯誤。
    """
    # add_help=False 再自己接一個 -h:argparse 內建的說明是英文的,
    # 這裡要印的是檔頭那份中文說明(__doc__)。
    ap = argparse.ArgumentParser(add_help=False)
    ap.add_argument('gamedir', nargs='?')
    ap.add_argument('--scan', action='store_true')
    ap.add_argument('--build', metavar='檔名')
    ap.add_argument('--add', action='append', default=[], metavar='相對路徑')
    ap.add_argument('--name', default='')
    ap.add_argument('--author', default='')
    ap.add_argument('--version', default='1.0')
    ap.add_argument('--note', default='')
    ap.add_argument('--preview', metavar='檔名')
    ap.add_argument('--install', metavar='檔名')
    ap.add_argument('--apply', action='store_true')
    ap.add_argument('--restore', action='store_true')
    # 還原時放行「驗不出來」的那一種備份。預設不放行:驗不出來就不蓋,
    # 什麼都不做永遠比蓋一份可能是半截的東西上去安全。
    ap.add_argument('--allow-unverified', action='store_true', dest='allow_unverified')
    # 放行「會執行的程式碼」(.exe .dll .bat 等,或內容開頭是 MZ 的檔)。預設不放行,
    # 換掉這類檔等於讓做這一包的人在你的電腦上跑程式。見檔頭安全網第 7 條。
    ap.add_argument('--allow-code', action='store_true', dest='allow_code')
    ap.add_argument('--selftest', action='store_true')
    ap.add_argument('-h', '--help', action='store_true')
    a = ap.parse_args()

    if a.selftest:
        # 自我測試自己造一個假的遊戲資料夾,不需要也不會碰到真的遊戲。
        return _selftest()
    if a.help or not a.gamedir:
        print(__doc__)
        return 0
    g = a.gamedir
    # 用 attrib.dat 當「這裡是遊戲資料夾」的路標:每一份安裝都有它,
    # 而且位置固定在 data/database/ 底下。找不到就是路徑指錯了層。
    if not os.path.isfile(os.path.join(g, 'data', 'database', 'attrib.dat')):
        raise Stop('%s 底下找不到 data/database/attrib.dat。\n'
                   '  第一個參數要是**遊戲資料夾**(裡面有 data 那一層)。' % clean_text(g, 300))
    if a.scan:
        return cmd_scan(g)
    if a.build:
        if not a.name:
            raise Stop('--build 要同時給 --name "你的模組叫什麼"。')
        return cmd_build(g, a.build, a.name, a.author, a.version, a.note, a.add)
    if a.preview:
        return cmd_preview(g, a.preview, a.allow_code)
    if a.install:
        return cmd_install(g, a.install, a.apply, a.allow_code)
    if a.restore:
        return cmd_restore(g, a.allow_unverified)
    print(__doc__)
    return 0


# 雙向自我測試:安全的要放行,不安全的一個都不能漏
assert is_safe_relpath('data/database/attrib.dat'), '正常路徑被誤殺'
assert not is_safe_relpath('/etc/passwd'), '絕對路徑沒擋'
assert not is_safe_relpath('../../etc/passwd'), '父層跳脫沒擋'
assert not is_safe_relpath('C:/Windows/system32'), '磁碟機代號沒擋'
# NTFS 的另一條資料流(Alternate Data Stream):冒號不在第二個字元,
# 舊版的 rel[1] == ':' 那一道整條放行。
assert not is_safe_relpath('data/file.txt:payload'), '另一條資料流的冒號沒擋'
assert not is_safe_relpath('~/.ssh/id_rsa'), '家目錄沒擋'
assert not is_safe_relpath('data/\x00evil'), 'NUL 沒擋'
assert not is_safe_relpath('data/CON'), 'Windows 保留檔名沒擋'
assert not is_safe_relpath('data/com1.txt'), 'COM1 沒擋'
assert not is_safe_relpath('data/evil.'), '結尾的點沒擋'
assert not is_safe_relpath('data//x'), '空路徑段沒擋'
assert is_safe_relpath('data/CONFIG.dat'), 'CONFIG 不是保留字,不可以誤殺'

# 備份完整性:壞的要擋,好的不可以誤殺。
# 用組出來的位元組直接測 truncation_evidence 的兩個純檔頭分支,不碰任何真實檔案。
assert _pe_min_size(b'notanexe') is None, '不是 PE 的東西不可以被當成 PE'
assert _looks_texty(b'0 first_name,1 last_name\r\n'), '純文字被誤判成二進位'
assert not _looks_texty(b'BIGF\x00\x01\x02\x00'), '二進位被誤判成純文字'
assert read_receipt(os.devnull) is None, '讀不到收據要回 None,不可以爆掉'

# ─────────────────────────────────────────────────────────
#  「-O 拒跑」那道守門的餌(2026-09-24 加)
#
#  自我測試一開頭有一道守門:在 python -O 底下拒跑(-O 會把 assert 整段拿掉,
#  測試會變成一片假的綠燈)。那一行以前直接問 sys.flags.optimize,
#  而 sys.flags 是唯讀的,同一個程序裡沒辦法把 -O 打開又關掉 ——
#  所以那道守門一直沒有餌:哪天被拆掉,自我測試照樣全綠,沒有人會發現。
#  現在守門改問 _optimize_level(),自我測試就能暫時把它換掉來下餌。
# ─────────────────────────────────────────────────────────
def _optimize_level():
    """python 的 -O 等級:沒加 -O 是 0,加 -O 是 1,加 -OO 是 2。"""
    return sys.flags.optimize


class _OptGuardPassed(Exception):
    """_optimize_bait() 用的記號:再叫一次自我測試時,守門放行、走到了下一行。"""


def _optimize_bait(selftest_fn):
    """-O 守門的餌與陰性對照。回傳 (餌被擋下來了, 陰性對照被放行了)。

    自我測試在守門的下一行就叫這一支,這一支再叫兩次 selftest_fn(輸出收起來不印):
      · 餌:先把 _optimize_level 換成「回傳 1」(假裝開了 -O)。那一次必須在守門
        那裡就停下來 —— 結束碼 2,而且印出來的那句話提到 -O。
      · 陰性對照:換回原本那一支之後再叫一次,守門必須放行。放行之後的下一行
        就是這裡,所以那一次會丟出 _OptGuardPassed 立刻收工 ——
        不會把整套自我測試再跑一遍,也不會在磁碟上留下任何東西。
    守門被拆掉的話,餌那一次也會一路走到這裡、丟出 _OptGuardPassed,就算沒擋下來。
    """
    if getattr(_optimize_bait, 'busy', False):
        raise _OptGuardPassed()
    import contextlib
    import io
    global _optimize_level
    real = _optimize_level

    def once():
        buf = io.StringIO()
        try:
            with contextlib.redirect_stdout(buf):
                rc = selftest_fn()
        except SystemExit as e:
            rc = e.code
        except _OptGuardPassed:
            rc = '放行'
        except Exception as e:
            rc = '例外 %r' % (e,)
        return rc, buf.getvalue()

    _optimize_bait.busy = True
    try:
        _optimize_level = lambda: 1
        try:
            rc, said = once()
        finally:
            _optimize_level = real
        rc2, _said2 = once()
    finally:
        _optimize_bait.busy = False
    bait = rc == 2 and '-O' in said
    neg = rc2 == '放行' and _optimize_level is real and _optimize_level() == 0
    return bait, neg


if __name__ == '__main__':
    try:
        sys.exit(main())
    except Stop as e:
        print()
        # 保底:Stop 的訊息有的是多行(我們自己寫的),所以放行 \n;
        # 其餘控制字元一律換成看得見的寫法。不可信的欄位在組訊息的時候已經各自洗過一次。
        print('  ✗ %s' % clean_text(str(e), 4000, keep='\t\n'))
        print()
        sys.exit(1)
    except KeyboardInterrupt:
        # 「中止了」這三個字不夠。要講的是**檔案動到了沒有**,
        # 而那幾種狀況要玩家做的事完全相反。收尾的內容見 report_interrupt();
        # 結束碼一律 130(shell 對 Ctrl-C 的慣例),
        # 不可以是 0 —— 批次檔看到 0 會當成裝好了。
        report_interrupt()
        sys.exit(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.
# ─────────────────────────────────────────────────────────────

想更進一步