教學 › 球場超過 10MB 就當機

球場超過 10MB 就當機

這是社群傳了二十年的一條規矩。大家的理解是「球場做得太精緻、檔案太大」。 把檔案量開之後,原因有一半不是那樣 —— 兩包「會跳出」的球場裡,大部分的肥肉是沒有人指向的垃圾,一行指令就能清掉; 但也有幾個是真的做得比較精緻,那種一個位元組都省不了。 這一頁教你怎麼分辨,以及兩種各該怎麼辦。

難度

★☆☆ 一行指令

時間

約 5 分鐘

可以還原嗎

可以,一行指令

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

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

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

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

  1. 先做備份。這一課會改檔案。
  2. 先量,完全唯讀。下載 mvp_shrink_big.py。下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列,把字刪掉改打 cmd 按 Enter,後面的指令都在開出來的這個視窗打(Mac 開終端機先打 cd ~/MVP_Tools)。然後跑

    Windows:

    python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big"

    Mac / Linux:

    python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big"
    它印「這個檔很乾淨,清了也省不了什麼」就到此為止,你的球場不是這個原因。 (另外兩句話也是同一個意思:「沒人指到的只有 N 個位元組,清了也省不了什麼」、 「重排之後不會變小 —— 那些沒人指到的位元組排在第一筆資料之前……這個檔不用清」。)
  3. 預覽會變多小

    Windows:

    python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink

    Mac / Linux:

    python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink
  4. 確定了才真的清。要看到「複驗:N 個項目全部逐位元組相同,檔頭大小欄正確 ✅」

    Windows:

    python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink --apply

    Mac / Linux:

    python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink --apply
  5. 進遊戲,開那座球場。這一步不能跳。不對就一行還原

    Windows:

    python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --restore

    Mac / Linux:

    python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --restore

⚠️ 中文版一進大球場就跳出的話,先看〈中文版配大球場就跳出〉—— 清球場救不了那一個。
第一步永遠是量,不是清。想知道為什麼,往下讀;只想複習,跳到📌 重點整理。

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

先看那條規矩本身

玩這款遊戲的人多半聽過:球場模組檔案太大,中文版就會跳出。 班主任的資料夾裡,甚至有一個資料夾就直接取名叫 stadium - 10MB —— 那是二十年前留下來的警告標籤。

⚠️ 順帶一提:這條規矩的數字有兩種版本 —— 這裡是 10MB, 另一課寫的是 20MB。 兩個數字貼在不同的檔案上,而且其中一個已經被原版自己推翻 (那一課有完整比較)。

⚠️ 2026-09-05 補上:本站後來把真因找出來了,而它不是「球場檔不能超過 10MB」那回事。 2026-08-28 晚上量到卡住的是遊戲寫死的那塊 64 MB 記憶體池 —— 本站把一顆球場照這一課清到 8.51 MB(已經低於這條規矩) 中文版照樣跳出,同一顆檔跑英文版完全正常,把池子改成 128 MB 就玩得動。 所以這一課清孤兒值得做(省下來的空間是實打實的), 但它不是那條規矩的解答;清完仍然超過 10MB 的那幾顆, 先看中文版配大球場就跳出那一課, 不必急著換回原版。

本站把那包 83 個球場檔跟剛安裝好的原版逐檔比對:

球場剛安裝好的原版社群這包倍數
skydnite.big3.42 MB18.44 MB5.4×
rfksnite.big2.88 MB14.95 MB5.2×
propnite.big3.28 MB12.64 MB3.8×
propday.big3.26 MB12.52 MB3.8×
skydday.big3.43 MB12.09 MB3.5×

83 個檔裡有 82 個比原版大。到這裡為止, 「社群把球場做得更精緻,所以檔案變大」看起來完全合理。

但打開來看,事情不是那樣

封裝檔的開頭有一份目錄,記著裡面每一個項目在第幾個位元組、有多長。 把 skydnite.big 的目錄攤開來加總:

檔案大小18.44 MB
目錄裡 88 個項目加起來5.19 MB
目錄沒有指到的位元組13.25 MB(71.9%)

這個檔有七成二是沒有人指向的垃圾。 而且原版與社群版的項目數都是 88 個 —— 內容根本沒有變多。

怎麼知道這不是這個格式本來就這樣

本站把剛安裝好的原版那 87 個球場檔全部量了一遍:

孤兒比例中位數最高
剛安裝好的原版(87 個)0.004%0.006%
社群這包(83 個)0.02%71.84%

EA 出貨的檔幾乎沒有孤兒。所以那 13 MB 不是格式的性質,是後來被加進去的。 而且社群這包大多數也很乾淨,問題集中在少數幾個檔。

⚠️ 2026-08-28 訂正:原版那兩格本來寫 0.1%/0.1%, 那是用舊算法(把檔頭與目錄也算成孤兒)再四捨五入到小數一位。 換成本站現在的定義是 0.004%/0.006% —— 比原本寫的更乾淨,結論不變但數字要對。
(2026-09-05 訂正:這裡原本寫「社群那一欄本站沒有重新量,那 83 個檔的資料夾已不在手邊」—— 那句話是錯的。資料夾還在,用同一個定義重量過: 0.02%(中位數)/71.84%(最高),83 個檔, 跟上表顯示的一致。)

那 13 MB 是怎麼跑進去的

因為安全的改法就會產生它。

要改封裝檔裡的一張圖,最安全的做法是把新的那份接到檔案最後面,然後只改目錄裡那一項的位置 ——本站其他課教的就是這個。 這樣原本的資料一個位元組都不會動,改壞了也救得回來。

代價是:舊的那份還躺在原地,只是沒有人再指向它。

改之前   [檔頭][目錄][圖A 舊][圖B][圖C]
改一次   [檔頭][目錄][圖A 舊 · 沒人指了][圖B][圖C][圖A 新]
改十次   [檔頭][目錄][死][死][死][死][死][死][死][死][死][圖B][圖C][圖A 第10版]

做一個球場模組要來回調整幾十次。每調一次就留下一份屍體。 那 13.25 MB 就是這樣累積出來的 —— 它從來沒有進過遊戲畫面,只是佔著檔案。

為什麼「檔案大」會害到遊戲,而不是「內容多」

本站在這一課沒有量遊戲執行時的記憶體,所以下面這段是推論:

如果遊戲是把整個封裝檔讀進記憶體、再照目錄去找東西(2005 年很多引擎這樣做), 那麼 18 MB 的檔就是 18 MB 的記憶體, 不管裡面有多少是垃圾。而遊戲是 32 位元的, 位址空間只有 2 GB。

這個推論解釋得了一件別的說法解釋不了的事: 本站測試機的 models.big 比原版大了 371 MB(3.2 倍)卻沒事。 如果問題是「總量太大」,那個檔早就該先爆。 但球場是開一場比賽時整個載進來的,而 models.big 是用到誰才載誰。

⚠️ 單位:本頁的 MB 一律是 1,048,576 位元組(跟這一課的腳本印出來的一樣), 所以那個差是 371 MB(535.86 − 164.98)。 另一課同一件事寫 389 MB, 那是用 1,000,000 位元組算的 —— 兩個數字是同一個差,只是尺不一樣。

⚠️ 2026-09-05 訂正:上面這段推論不能拿來解釋「中文版跳出」。 本站 2026-08-28 晚上量到,卡住的是遊戲寫死的一塊 64 MB 記憶體池, 而不是「球場檔不能超過 10MB」那條線:一顆清到 8.51 MB 的球場中文版照樣跳出, 同一顆檔跑英文版完全正常,把池子改成 128 MB 就玩得動 (三種池子大小、四次實機測試的表在中文版配大球場就跳出)。

未解 還沒量的是上面那個推論本身 —— 「一顆 18 MB 的封裝檔載進去實際佔多少記憶體」本站沒有量過。 這一課能確定的仍然是:清掉那 13 MB 之後檔案變回 5.20 MB,內容一個位元組都沒少。

解法:把沒人指到的位元組清掉

既然那些位元組沒有人指向,就把有指到的照原順序重排、其餘丟掉。 每一個項目的位元組原封不動,只有它們在檔案裡的位置變了。

清之前清之後
skydnite.big18.44 MB5.20 MB
目錄項目數8888
每一項的內容(md5 逐項比對)88 個全部相同
清完之後剩下的非目錄位元組71.9%0.11%

0.11% ——「跟剛安裝好的原版一樣乾淨」。 那 0.11% 是檔頭、目錄本身,以及把每一項對齊到 4 的倍數的補齊位元組。

這跟本站「絕不重新打包」的鐵律衝突嗎

本站其他課一律說「不要整包重新打包,會弄丟目錄沒指到的資料」。這一課做的正好是那件事, 所以要說清楚為什麼這裡可以:

但最後一關還是你。清完之後一定要進遊戲看一次 —— 每個人手上那包不一樣,本站驗得了檔案,驗不了你那一份。

⭐ 2026-08-28:整包清完之後,實機驗過了

上面那些數字都是檔案層級的證明:清完之後 88 個項目逐位元組相同。 但檔案對不代表遊戲吃得下去 —— 那一關本站原本沒辦法自己驗。

現在驗了。把一整包 87 個球場檔全部清過一遍,然後實際進遊戲玩:

檔數合計最大的一個超過 10 MB 的
清之前87647.6 MB15.14 MB24 個
清之後87406.8 MB12.74 MB2 個
省下—240.8 MB(37.2%)——

⚠️ 2026-08-29 重量訂正:這一列原本寫 394.9 MB/最大 9.67 MB/0 個。 那是班主任清完之後另外留的那一份的數字,而那一份不是純粹「2023 那包清一遍」的結果 —— 裡面的 wrignite.big 與 coornite.big 被換成另一包社群球場的版本再清 (12.74 → 7.45 MB、11.05 → 4.45 MB,兩個加起來剛好就是差的那 11.9 MB)。 只清、不換的話,2023 那包 87 個檔清完是 406.8 MB, 還剩 2 個超過 10 MB —— 跟本頁下面〈結論對了一半〉那一段的數字一致。 這一格是本頁自己打自己的臉:兩個數字並存了一段時間,而且 recap 採用的是被換過的那一組。

整包清完之後遊戲照常玩。 這是本站第一次能同時給你兩邊的證明 —— 檔案一個位元組都沒少(88 / 88 逐位元組相同),而且遊戲讀得動。

⚠️ 誠實邊界:實機測試是一個人、一台機器、一包球場。 它證明的是「這件事做得到」,不是「你那包一定沒問題」。 備份還是要做,清完還是要進遊戲看一次。

⚠️ 但這不是全部的原因 —— 有些球場是真的那麼大

這一節是本頁上線幾小時後補的。 拿另外兩包社群球場去驗上面那套說法,結論只對了一半。

班主任手上有三包球場,二十年前就照「會不會跳出」分好了。 把三包全部量開:

哪一包檔數大小中位 孤兒中位孤兒最高超過 10MB
剛安裝好的原版873.24 MB 0.00%0.01%0 個
標「不超過 10MB」的安全包873.24 MB 0.00%0.01%0 個
標「10MB」的那包834.73 MB 0.02%71.84%5 個
2023 年那包877.57 MB 40.06%68.01%24 個

⚠️ 2026-08-28 重量:孤兒比例全部改用 本站統一的定義 (不把檔頭與目錄算成孤兒)。前三包原本寫 0.1%,那是舊算法四捨五入到小數一位; 最後一包 40.1%/68.0%/24 個本來就是對的,重量之後 40.06%/68.01%/24 個。
⚠️ 也要說清楚量的是哪一份快照:上表量的是清理之前的那一份。 (2026-09-05 訂正:這裡原本寫「那一包現在直接去量會得到 0.00%,因為班主任已經照這一課把它清乾淨了」—— 不是那樣。班主任的遊戲現役資料夾裡放的仍然是未清理的那一份, 跟三個備份資料夾逐位元組相同,量出來就是上表這一欄的數字; 清理後的結果是另外存成一份,那一份量到的是中位數 2.82%、最高 5.28%, 也不是 0.00%。)

先講一個意外的發現:那個「安全球場包」根本就是原廠檔

本站把它跟剛安裝好的官方中文版逐檔比對雜湊值: 87 個檔全部逐位元組相同。

也就是說,社群傳的「用不超過 10MB 的球場」跟 「把球場換回剛安裝好的樣子」是同一個動作。 那包不是精選過的小模組,是原廠檔的備份。

結論對了一半

對的那一半:兩包「會跳出」的裡面都有孤兒嚴重的檔, 而「安全」的那包一個都沒有。2023 那包清一遍可以從 648 MB 降到 407 MB(省 37%), 超過 10MB 的檔從 24 個掉到 2 個。

錯的那一半:清完之後還是有檔超過 10MB。它們的孤兒幾乎都是 0%, 最高的一個也只有 5.7%,清完仍然有 12.00 MB ——

球場原版社群版清完孤兒
rfksnite.big2.88 MB14.95 MB14.95 MB0%
wrignite.big3.36 MB12.74 MB12.74 MB0%
propnite.big3.28 MB12.64 MB12.64 MB1.4%
propday.big3.26 MB12.52 MB12.00 MB5.7%
coornite.big3.43 MB11.05 MB11.05 MB0%

⚠️ 口徑:這張表是把兩包社群球場合起來看,列的是「清完之後仍然超過 10 MB」的檔。 rfksnite、propnite、propday 來自標「10MB」那包, wrignite、coornite 來自 2023 那包。 所以這裡有五列,而上一段講 2023 那一包時說的是 2 個(wrignite 與 coornite),兩者不衝突。 兩包裡都有一顆叫 rfksnite.big,但不是同一個版本:這裡列的是標「10MB」那包的 14.95 MB(孤兒 0%,清完還是 14.95 MB);標「2023_10MB」那包的那一顆是 15.14 MB、 孤兒 45.0%,清完剩 8.51 MB —— 中文版配大球場就跳出 用的是後面這一顆,不要把兩顆當成同一個檔。 孤兒那一欄照本站統一的定義算 (不把檔頭與目錄算成孤兒),所以會比腳本印的百分比略小一點。 (2026-09-05 訂正:propnite 那一格原本寫 0%、propday 原本寫 4%。 重量之後是 1.4% 與 5.7%;4% 是腳本印的「省 4%」, 那是縮小比例不是孤兒比例,當初填錯欄了。)

五個檔的項目數都是 88 個,跟原版一模一樣。內容沒有變多。

⚠️ 這一段本站在同一天又修了一次

初稿寫「做這幾個模組的人是好好重新打包過的,他們的檔大是因為東西真的比較精緻」。 只對了一半。後來多量一個東西才發現:

球場檔裡「沒有壓縮」的內容佔多少佔目錄內容逐檔中位數
剛安裝好的原版(87 個檔)0.0%0%
標「安全」那包(也是原廠檔)0.0%0%
標「10MB」那包13.5%(58 MB)0%
2023 那包17.4%(69 MB)0%

EA 出貨的球場,一個位元組的未壓縮內容都沒有。 社群那兩包各有五、六十 MB 是存回去時沒有重新壓縮的。

所以那五個「清完還是很大」的檔,同時是兩種原因: 貼圖確實換大了,而且其中一部分還沒壓縮。

但要講清楚比例:逐檔中位數是 0% —— 大多數社群球場檔還是好好壓縮過的,沒壓縮的集中在少數幾個。 所以「社群不會壓縮」是錯的說法,正確的是「有幾個檔漏掉了」。

⭐ 又是同一個坑:看到「孤兒 0%」就直接推論「那一定是內容變多」。 其實還有第三種可能,而我第一次沒去量。

所以正確的說法是:球場檔變大有兩個完全不同的原因

① 孤兒堆積② 貼圖真的變大
怎麼看出來孤兒比例高(幾十 %)孤兒比例接近 0
成因做模組時一次次接在檔尾換成更高解析度的貼圖
這一課有沒有用有,免費省掉沒有,一個位元組都省不了
那怎麼辦清完就好,畫面完全不變只能換回原版,或接受它

所以第一步永遠是「量」,不是「清」。 腳本不加參數就是量,它會直接告訴你屬於哪一種。

⚠️ 孤兒比例高不等於清得掉。沒人指到的位元組如果排在 第一筆資料的起點之前,那一段跟檔頭與目錄一起被原樣留著,這一課清不掉。 本站測試機 680 個封裝檔裡「孤兒 ≥2% 且 ≥1 KB」的有 331 個, 其中 104 個重排之後不會變小(data/frontend/igonly.big 孤兒 95.8% 就是一個)。所以判準要看腳本印的「清掉之後大約會變成多大」, 不是孤兒的百分比 —— 這種檔腳本會直接說「這個檔不用清」,連備份都不會做。

⚠️ ②那一欄的「接受它」只是說檔案縮不下來。 如果你是因為中文版跳出才來清球場的,那件事有別的解 —— 見中文版配大球場就跳出。

⚠️ 2026-08-28 再補:還有第三個原因 —— 壓縮被丟掉了

本站後來在一批舊模組裡量到第三種「變大」。那是一個換打擊護具顏色的模組, 八個顏色版本,每一個都夾帶一整個遊戲封裝檔:

檔案大小解開後內層有沒有壓縮
剛安裝好的原版1,212,7113,082,320有
七個顏色版本3,081,2123,081,172沒有
第八個(原版備份)1,212,7113,082,320有

大了 2.5 倍,而它們只改了 34 個項目裡的 1 個。 其餘 33 項跟 EA 的逐位元組完全相同(本站用雜湊逐項比過)。

換句話說:為了換一張貼圖,多帶了 1.87 MB 完全沒必要的東西 —— 不是因為多了內容,是因為存回去的時候沒有把壓縮做回去。

這支腳本幫不上忙:它清的是沒人指到的位元組, 而這個檔沒有孤兒,只是沒壓縮。要救它得重新壓縮,那是另一件事。

⭐ 那八個版本裡有一個叫「原版」的,本站量下來跟剛安裝好的一模一樣 —— 做那個模組的人順手附了還原用的原檔。二十年前的好習慣。

所以「封裝檔為什麼變大」一共有三個答案

怎麼認出來這一課有沒有用
① 孤兒堆積孤兒比例高(幾十 %)有,免費省掉
② 貼圖真的換大了孤兒接近 0,項目數不變但每項變大沒有
③ 壓縮被丟掉孤兒接近 0,解開後的大小幾乎等於檔案本身沒有,要重新壓縮

三種都可以用同一個動作分辨:先量。 腳本印出來的那三個數字(檔案大小、目錄指到的內容、孤兒佔比)就夠了。

本站為什麼把這一段補上,而不是把上面改掉

上面那段推論不是錯的 —— 孤兒真的存在、真的佔 71.9%、清掉真的省 13 MB。 錯的是把它當成唯一的原因。

會犯這個錯是因為第一個量的檔(skydnite.big)剛好是最極端的那個。 從最漂亮的樣本推出通則,是本站反覆踩到的同一個坑。 留著這一段,是要讓你看到修正長什麼樣子。

前置

👉 你要做的事

1

下載腳本

mvp_shrink_big.py(2,590 行,零相依)

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

2

先量:這個檔裡有多少是沒人指到的

完全唯讀。先知道有沒有得清,再決定要不要動。

Windows:

python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big"

Mac / Linux:

python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big"

孤兒不到 2% 的話它會直接告訴你「這個檔很乾淨,清了也省不了什麼」。 另外兩種情況它也會叫你到此為止:孤兒不到 1 KB(印「沒人指到的只有 N 個位元組」), 以及重排之後不會變小(那些位元組排在第一筆資料之前,這一課清不掉)。 這三句都代表「不用清」,腳本不會叫你 --apply,也不會做備份。

3

預覽會變多小

Windows:

python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink

Mac / Linux:

python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink
4

確定了才真的清

會先備份,寫完把 88 個項目逐位元組跟原檔比對;比不過會當場自動還原。

Windows:

python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink --apply

Mac / Linux:

python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink --apply
5

進遊戲,開那座球場

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

✅ 成功標準

❌ 出錯處理

看到什麼怎麼做
can't open file '…mvp_*.py' 命令視窗跟腳本不在同一個資料夾。 打開放著那個 .py 的資料夾(照第 0 課操作六搬過的話就是 MVP_Tools),在網址列打 cmd 按 Enter,用新開的視窗再跑一次(Mac 用 cd 切過去,例如 cd ~/MVP_Tools)。 還是一樣的話,是檔名對不上:瀏覽器可能在檔名後面加了 (1),把它改成跟指令裡一模一樣
這個檔很乾淨,清了也省不了什麼正常。這個檔沒有孤兒問題,不是你的球場的原因
沒人指到的只有 N 個位元組,清了也省不了什麼正常。孤兒不到 1 KB,清了看不出差別
重排之後不會變小 —— 那些沒人指到的位元組排在第一筆資料之前 正常,而且這個檔清不掉。那一段跟檔頭與目錄一起被原樣留著,腳本不會動它
目錄裡「○○」指到 ○~○,超出檔案大小那個檔已經損毀了,腳本不敢動。從備份或原版拿一份
目錄宣告 N 筆,只解析得出 M 筆 —— 名字可能比預估的長,不敢動這個檔 腳本刻意停手(硬清下去會弄丟那幾筆而複驗還給綠燈)。回報給本站看一下
作業系統不讓我讀寫這個檔
(或「讀不到 ○○」)
權限不足、磁碟滿了、外接碟被拔掉,或是遊戲正開著鎖住檔案。關掉遊戲、換個位置再試。 下面如果接著說「已經換成清過的那一版」,代表出事的時候檔案已經換好了: 排除上面的原因(例如把外接碟插回去)之後,照它印出來的那一行 --restore 跑一次就回到動手前
複驗有 N 項沒過 腳本會當場自己還原,做完印「✓ 已自動還原」—— 你只要回報。 萬一連自動還原都沒成功,畫面上會給你一行可以直接複製的 --restore,照著跑一次再回報
你按了 Ctrl-C 照畫面最後那句話做,它有四種講法。 說「你的遊戲檔一個位元組都沒有動到」=什麼都不用做 (旁邊可能留下一個以 . 開頭的隱藏暫存檔,刪掉就好); 說「已經換成清過的那一版」=跑它印出來的那一行 --restore; 說「檔案現在就是備份的那一份」=還原已經做完了,不用再做什麼; 說「中斷的時候正在替換 ○○,換好了沒有這邊沒辦法確定」 =腳本自己也不知道換到哪一步了(這一句幾乎不會出現,它是最後一層保險), 照它印出來的那一行 --restore 跑一次就一定回得去, 也可以先拿旁邊那份 .shrinkbak 跟它比對
清完之後遊戲反而開不起來 --restore,然後回報 —— 那代表那些孤兒資料其實有用,本站的推論就要改寫

一行就能還原

Windows:

python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --restore

Mac / Linux:

python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --restore

本站實測:還原之後 SHA-256 與動手前完全相同。

想確認這支腳本本身沒被改壞

Windows:

python -I mvp_shrink_big.py --selftest

Mac / Linux:

python3 -I mvp_shrink_big.py --selftest

這一行不需要遊戲資料夾,也不碰任何遊戲檔——它在系統的暫存資料夾裡自己造幾個迷你的假封裝檔來清,跑完整個刪掉。 本站這台 Mac 跑出來是 48 項全部打勾;全部打勾就代表它還是好的(結束碼 0;任何一項紅了就是 1)。

裡面有餌(複驗故意說有問題、換名那一步故意失敗、換名之後故意讀不到檔、自動還原故意失敗、半截的備份、 指到資料夾外面的符號連結、孤兒排在第一筆資料之前…), 也有陰性對照——先證明沒有餌的時候真的清得掉、還原得回來, 免得「整支腳本罷工」被誤讀成「保護有效」。 有些項目要建符號連結(Windows 沒開開發人員模式就建不了),這台做不到就標 ⏭ 跳過,而且畫面上會講出來, 那幾項等於沒驗到。不要加 -O:那會把 assert 拿掉、測試有機會假綠,所以加了會直接拒跑(結束碼 2)。

📋 回報範本

清孤兒資料 狀態:✅ / ❌
球場檔名:
腳本說:不用清 / 有清
清之前 / 清之後:      MB →      MB
複驗那一行:
進遊戲:開得起來 / 開不起來 / 畫面怪怪的

這一課學到的東西可以用在別的地方

「檔案大」跟「內容多」是兩件事。這一課的量法(把目錄裡每一項的長度加起來,跟檔案大小比) 對任何一個封裝檔都適用,不只球場。

如果你手上有哪個模組讓遊戲變慢或不穩,先用這支腳本量一下 —— 先確認問題是什麼,再決定要不要動它。 這也是本站每一課的順序。

📌 重點整理

這支腳本在做什麼

不想讀程式碼也沒關係,這一段把它的流程講完。

整支腳本只做一件事:照著封裝檔開頭那份目錄,把「有人指到」的內容重新排好, 把「沒人指到」的位元組丟掉。 它先讀檔頭(BIGF 這四個字元、檔案總長度、目錄有幾筆), 再一筆一筆走過目錄,只把每一項的長度加起來。 這個總和跟檔案實際大小的差額,就是沒有人指向的那些位元組。 起點不加進這個總和,它另外只取最小的那一個, 當作「資料區從哪裡開始」,算「清完會省下多少」以及真的重排時才用得到。 不加任何參數時,它到這裡就停了,把數字印給你看,一個位元組都不會寫。

⚠️ 這裡容易看錯:加總的只有長度,起點不加進去。 兩個一起加會得到一個沒有意義的數字。本站測試機上那顆 data/models.big(561,891,312 位元組、4,211 個項目), 起點與長度全部加起來會是檔案大小的 690 倍。

加了 --apply 才會動手:把每一項照原本的先後順序接著排下去 (每一項對齊到 4 的倍數),再回頭把目錄裡的起點改成新位置。 項目本身的位元組原封不動,改的只是它們在檔案裡的位置。 寫完會重新讀一次檔案、逐項比對內容,比不過就當場自己從備份還原(自動還原也失敗才叫你跑 --restore)。

哪一段做什麼為什麼要有它
main() 看你給了哪些參數,決定走診斷、預覽、寫入還是還原, 並且把所有錯誤收成一句中文。 還原排在最前面判斷,因為那是逃生口, 任何時候都該通。錯誤印成一句話而不是一整片紅字, 是因為這一課的讀者不是工程師。 結束碼三種:0 做完了、 2 它自己認得出來的問題(含備份是壞的、作業系統不讓讀寫)、 130 你按了 Ctrl-C。 --selftest 另外算:0 全部通過、1 有一項沒過、 2 在 python -O 底下拒跑。
size_field_order() 判斷檔頭第 5 到第 8 個位元組(記錄「檔案有多長」的那一欄) 是小端還是大端。 這一欄兩種順序都遇得到,寫死一種就會寫壞另一種。 判法是兩種都算一次,哪一種等於實際檔案大小就是哪一種 —— 順便驗出「這個檔壞了」。
big_entries() 把目錄拆成一筆一筆的 (名稱, 這筆在檔案裡的位置, 起點, 長度)。 名稱長度不固定(以一個 0x00 位元組結尾), 所以沒辦法直接算出目錄多長,只能一筆一筆往下走。 把「這筆在檔案裡的位置」也記下來,是為了之後只覆寫那 8 個位元組, 不必重寫整份目錄。 目錄宣告 N 筆而只解析得出 M 筆時它直接停手,不回傳半份目錄 —— 少解析到的那幾筆會被當成沒人指到的位元組清掉,而複驗是同一個函式重讀的、 一樣少那幾筆,於是一個被清壞的檔會拿到綠燈。
analyse() 把每一項的長度加起來,跟檔案大小比,算出孤兒有多少; 順便檢查有沒有哪一項指到檔案外面。 這就是這一課的量法本身,而且對任何一個封裝檔都適用,不只球場。 目錄自己不自洽的檔(指到檔外)代表它已經壞了, 那種檔一個位元組都不該碰。
cmd_check() 不加參數時跑的那一段:印出體檢報告, 並試著分辨「檔案大」是三種原因裡的哪一種。 它印的「清掉之後大約會變成多大」跟 --apply 真的做出來的大小 是同一套算法(planned_size())算的, 所以不會發生「這裡叫你清、那裡說不用清」。 這一課真正的重點是先量再決定。 孤兒堆積清得掉,貼圖真的變大跟存回去時沒重新壓縮都清不掉 —— 分不出來就會白忙一場。
cmd_shrink() 重排、寫入、複驗。複驗沒過就當場自動從備份還原。 沒加 --apply 就只算給你看。 預覽跟實際寫入走的是同一段重排程式碼, 所以你看到的數字就是真的會發生的事。 寫入是先寫一個隨機名字的暫存檔(跟原檔同一個資料夾, 以 . 開頭所以是隱藏的),flush 加 fsync 之後才用 os.replace 換名。 擋得住的是腳本中途被打斷(按 Ctrl-C、磁碟滿、外接碟被拔): 不會出現半截的球場檔。換名之前被打斷,留在旁邊的最多是那個隱藏暫存檔 (腳本收得掉的都會自己收掉),原檔還是動手前那一份;換名之後才被打斷, 畫面會照實說「已經換成清過的那一版」並印出可以直接複製的 --restore。 另外三件事:重排之後不會變小的檔連備份都不做,直接說不用清; 換名之前會把原檔的權限抄到暫存檔(不然 0700 的檔會被換成 0644); 寫的位置是符號連結就停下來,不跟著它走。 真的在寫入當下斷過電,先不加參數跑一次體檢,看目錄還對不對得上, 再決定要不要 --restore。 (2026-09-05 訂正:這一格原本寫「沒有呼叫 fsync, 斷電不在保證範圍內」—— 那句話當時是對的,現在已經補上了。)
_atomic_copy() 備份。先複製成同一個資料夾裡一個隨機名字的隱藏暫存檔 (以 . 開頭),整份到齊、fsync 落到磁碟之後 才改名成 .shrinkbak。 直接複製到備份的名字,中途被中斷(磁碟滿、外接碟被拔、按了 Ctrl-C)會留下一個半截卻叫得出名字的備份, 下次還原就會拿它蓋掉正本。改名這一步是原子的,沒有中間狀態。 名字為什麼要隨機:固定的名字別人可以先佔住 —— 在同一個資料夾先放一個同名的符號連結指到資料夾外面, 複製就會沿著它把外面那個檔案蓋掉。隨機名字是用「已經存在就建不起來」 的方式開的,佔不住,也跟不到連結。
_restore_from_backup() 還原之前先驗備份是不是完整的: 不是 0 位元組、檔頭宣告的長度要對得上、認不出格式的還有一條「不得小於正本一半」的地板。 2026-08-29 實測過:把備份截成一小段, 當時的還原指令照樣印「已還原」,然後把好好的遊戲檔換成半截的。 把關寫在還原這一端,比寫在備份那一端重要。
cmd_restore() --restore 走的路,而且不是直接蓋回去: 先在正本旁邊寫一個隨機名字的暫存檔、邊寫邊算 SHA-256, 寫完再讀回來算一次,長度與雜湊都等於備份才換名。 換完再把兩個檔整份讀出來比一次。 直接複製的第一個動作是把正本截成 0: 複製途中斷電、磁碟滿、外接碟被拔掉、按了 Ctrl-C, 玩家手上就只剩半截的遊戲檔,而那份備份只有一次機會。 改成先寫暫存再換名之後,換名之前任何一步失敗,正本都是原封不動的那一份。 至於最後那一次比對,是因為「印了成功」跟「真的還原了」是兩件事。
DataError 整支腳本唯一一種「停下來,而且我知道為什麼」的錯誤。 凡是檔案內容跟預期不符,一律停下來問人,不要猜。 猜錯的代價是別人的遊戲檔。

下面那份原始碼裡還有兩塊你在這張表上找不到的東西:EA 壓縮格式的解壓/壓縮 (qfs_decompress 等),以及圖片容器的讀寫(fsh_first_image 等)。 這一課完全沒有用到它們 —— 它們是本站腳本共用的一塊, 站上 30 支腳本裡有 13 支帶著前者、8 支帶著後者(換球衣、換大頭照那幾課真的會用)。 這個分母會隨新課增加,要自己數就跑 find site/tutorials -name '*.py' | wc -l。 留著是為了讓每一支都是「一個檔就能跑」。腳本檔頭的 import re 與 import zlib 同理,這一支也沒有用到。 另外還有一支 append_entry(),用程式碼講「孤兒資料從哪裡來」,這一課一樣沒有呼叫它 —— 要抄它去改別的封裝檔之前先看它上面那段註解:它是直接對原檔往檔尾接資料的舊寫法, 中途被打斷會留下「資料接上去了、目錄或檔頭還沒改完」的檔,只能還原。 把一套球衣裝進遊戲那一課的腳本用的是先做工作複本、最後一次換名的寫法,要抄請抄那一支。

完整原始碼

下面就是你剛才下載的那一支,一個字都沒有不同(本站有自動檢查在守這件事)。

展開 / 收合完整原始碼(2590 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_shrink_big.py — 把封裝檔裡「目錄沒指到的位元組」清掉

這是在解決什麼
-------------
社群傳了二十年的一條規矩:**球場檔超過 10 MB,中文版就會跳出**。
大家的理解是「球場做得太精緻、檔案太大」。

本站 2026-08-28 把一包社群球場實際量開,發現不是那樣:

    skydnite.big   檔案 18.44 MB,但目錄指到的內容只有 5.19 MB
                   → 其中 13.25 MB(72%)是**目錄根本沒指到的位元組**

而剛安裝好的原版,87 個球場檔的孤兒比例中位數是 **0.00%**(最高 0.01%)——
所以孤兒不是這個格式的性質,是**編輯過程留下來的殘骸**。

那些位元組是怎麼來的?因為安全的改法是「把新資料接到檔尾、只改目錄」
(本站其他課教的就是這個)。舊的那份資料還躺在原地,只是沒有人再指向它。
改一次留一份,改十次留十份。

這支腳本做的事:**把目錄指到的東西照原順序重新排好,把沒人指的丟掉。**
每一個項目的位元組原封不動,只有它們在檔案裡的位置變了。

⚠️ 這跟本站「不要重新打包」的鐵律衝突嗎?
   那條鐵律的理由是「你不知道孤兒資料有沒有用」。這裡的差別是:
   **剛安裝好的原版孤兒只有 0.00%**(中位數,最高 0.01%),證明這個格式本來就不該有孤兒。
   即使如此,這支腳本一樣會保留原檔,而且寫完會逐項比對位元組。
   最後還是要你進遊戲確認。

輸入是什麼、輸出是什麼
---------------------
輸入:一個 `.big` 封裝檔的路徑,就這樣。球場檔在 `<遊戲資料夾>/data/stadium/` 底下。
      不必先解開、不必準備別的檔案,腳本自己讀它的檔頭與目錄。

輸出分成四種,由參數決定,**預設那一種完全不寫檔**:

    (不加參數)          只印診斷:多大、幾個項目、目錄指到多少、孤兒多少
    --shrink            印「清完會變多小」的預覽,一樣不寫檔
    --shrink --apply    真的寫。旁邊多一份 `<原檔名>.shrinkbak`,原檔換成重排後的版本
    --apply             跟上面同一件事(單獨給 --apply 也會清,不必再加 --shrink)
    --restore           拿 `.shrinkbak` 蓋回原檔

另外有一個 --selftest(2026-09-24 加):不需要遊戲檔,也不碰任何遊戲檔,
只在系統暫存資料夾裡造幾個迷你的假封裝檔,替下面的守門下餌踩一次(不是每一道都有,
數字見檔尾 selftest() 的說明),證明「答案錯的時候它真的會擋」,跑完整個暫存資料夾刪掉。
它不接受 python -O(-O 會把 assert 拿掉,測試有機會假綠),加了會直接拒跑。

安全網在哪(四層,由外往內)
--------------------------
1. **預設唯讀。** 不給參數就只是量。要動到檔案得自己打 --apply,不會誤觸。
2. **先備份再寫,而且備份是原子的。** 見 `_atomic_copy`:先寫一個**隨機名字**的暫存檔
   (tempfile.mkstemp,建在備份要放的那個資料夾裡)再換名,途中被中斷只會留下那個暫存檔,
   不會留下一個半截卻叫得出名字的 `.shrinkbak`。
   已經有備份就**保留最早那一份** —— 第二次執行不會把「原版的備份」蓋成「已經改過的版本」。
3. **新檔先寫暫存、驗過了才換名。** 內容寫進同一個資料夾裡一個隨機名字的暫存檔,
   flush + fsync,**接著就地把那個暫存檔重讀一次驗過**(2026-09-06 加,見 `_verify_pack`)——
   不過就整份丟掉,壞的那一份根本不會換上去,遊戲檔一個位元組都沒有動。
   驗過了才 os.replace 換過去,而「換名」與「登記已經換過了」是一段
   **不可中斷**的區塊(見 `_NoInterrupt`):Ctrl-C 落不進那條縫裡,
   所以收尾說的話一定跟磁碟上的狀態一致。
4. **換完再複驗一次。** 重新讀一次目錄,逐項比對「長度一樣」與「內容逐位元組相同」,
   再確認檔頭的大小欄跟實際檔案大小相符。**任何一項不過就當場自動從備份還原**,
   然後丟 DataError(結束碼 2)。
   還原那一步也有把關(見 `_restore_from_backup`):半截的備份會被擋下來,不會拿去蓋正本;
   而且還原本身也是原子的(見 `_do_copy`)—— 先寫暫存、比對雜湊,最後才換名,
   中途失敗正本原封不動。

這四層之外,還有三道「乾脆不動」的閘門
--------------------------------------
· **你指的那個名字是符號連結,而這一次會寫檔 → 直接拒絕。**(2026-09-06)
  `--apply` 與 `--restore` 一律停在這裡,結束碼 2。跟著連結寫下去等於替你決定
  去動資料夾**外面**的檔,而你打的那個路徑上看不出來。
  只是量的那兩條路(不加參數、`--shrink`)不受限制,會跟著連結走到本體去量,
  印給你複製的下一行指令也會指到本體。
· **目錄解析不完整就停下來。**(2026-09-05)檔頭宣告 N 筆而只解析得出 M 筆時,`big_entries`
  直接丟 DataError。少解析到的那幾筆會被當成沒人指到的位元組清掉,而複驗是同一個
  函式重讀的、一樣少那幾筆 —— 一個被清壞的檔會拿到「全部逐位元組相同 ✅」的綠燈。
· **重排之後不會變小就不寫。**(2026-09-05)孤兒排在第一筆資料之前的那一段清不掉(見下面
  「做不到的事」),那種檔連備份都不做,直接說「這個檔不用清」。

做不到的事(這一段比上面那一段重要)
-----------------------------------
· **不會讓「內容真的很多」的檔變小。** 孤兒不到 2% 時腳本會直說清了也省不了什麼。
  貼圖換成更大張的(第②種)、或是存回去時沒有重新壓縮(第③種),這兩種它都治不了。
  `cmd_check` 會試著幫你分辨是三種裡的哪一種,但那是線索不是判決。
· **孤兒不等於清得掉。** 目錄沒指到的位元組如果排在**第一筆資料的起點之前**,
  那一段跟檔頭與目錄一起原樣留著,這一課清不掉。實測 `data/frontend/igonly.big`
  孤兒 95.8%,重排之後反而多 1 個位元組;本站測試機 680 個封裝檔裡,
  「孤兒 ≥2% 且 ≥1 KB」的有 331 個,其中 104 個重排後不會變小。
  這種檔腳本會直接說「不用清」,不會白做一次備份與重寫。
· **不重新壓縮、不重新編碼任何東西。** 每一項的位元組原封不動,只有它在檔案裡的位置變了。
· **不判斷孤兒資料「有沒有用」。** 它只認一件事:目錄有沒有指向它。
  如果有哪個模組靠著「目錄沒指到、但位置寫死」的資料運作,這支會把那些位元組清掉。
  (本站在剛安裝好的原版與兩包社群球場上都沒見過這種用法,但沒見過不等於不存在。)
· **不碰遊戲程式,也不碰你的王朝存檔。** 只動你在指令裡指名的那一個 .big。
· **不保證遊戲跑得起來。** 複驗只證明位元組沒變,不證明遊戲讀得動 ——
  所以最後一步永遠是「進遊戲確認那座球場開得起來」。

自包含:整支腳本就是這一個檔,只用 Python 內建模組,不需要安裝任何套件。
⚠️ 2026-09-03 訂正:上面這句原本後面還掛著「連讀寫 PNG 都是自己做的」,那在這一支上不成立。
   這個檔從頭到尾沒有一行 PNG 的程式碼:拿 png 去搜 mvp_shrink_big.py,
   搜得到的只有這一段訂正本身。這一課只搬位置,不解圖也不改圖。
   下面那幾塊 FSH 碰的是 EA 自己的 SHPI/FSH 圖片格式,不是 PNG,
   而且如同 QFS 那一段開頭已經寫的,它們在本支沒有任何地方呼叫;
   檔頭的 import re 與 import zlib 也同樣沒有用到。
   「不需要安裝任何套件」那半句本來就是對的:這個檔 import 的
   argparse / hashlib / os / re / shutil / signal / struct / sys / tempfile / zlib
   全是 Python 自己就附的模組。
   (2026-09-06 補:這一行原本漏列 hashlib 與 tempfile 兩個,那時候它們就已經在
    import 了;signal 是這一輪為了「換名那一段不可中斷」新加的。)
   (2026-09-24 補:--selftest 另外在函式裡面 import 了 io 與 contextlib,
    用來收起真正跑一次 main() 時印在畫面上的字 —— 同樣是 Python 自己就附的模組。)
   真的自己讀寫 PNG 的,是「換球員大頭照」「做一張新的球員臉皮」「換掉開機畫面」
   「換球隊隊徽」這四課的腳本。2026-09-03 在 site/tutorials/ 底下 29 支腳本上量,
   找得到 png_read 與 png_write 的就只有那四個檔。

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

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

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

def _refuse_if_symlink(p, what):
    """要寫的位置是符號連結就停下來,絕不跟著它走(2026-09-05 加)。

    ⚠️ **不可以用 os.path.exists() 判斷有沒有這個東西。** 連結還在、但它指向的檔案
       不存在時(dangling symlink),exists() 回的是 False —— 而那一行
       open(那個名字, 'wb') 照樣會沿著連結,在資料夾**外面**建出/蓋掉一個檔。
       這裡用 os.path.islink(走 lstat,不跟著連結),才看得到連結本身。
       (Windows 的 junction / mklink 這一輪還沒實測,不敢說這一行擋得住;
        擋得住的那一半是暫存檔改用 tempfile.mkstemp —— 那個名字別人猜不到,
        也就沒有先放一個連結在那裡等著的機會。)

    為什麼要有這一道:暫存檔與備份檔的名字如果可以被預測,別人(或某個
    模組管理器留下的殘骸)就能先在那個名字上放一個指到別處的連結。
    暫存檔那一半已經改用 tempfile.mkstemp 拿掉可預測性;
    正本與備份這兩個名字是使用者指定的、沒辦法隨機,所以改成「是連結就拒絕」。
    """
    p = os.fspath(p)
    if os.path.islink(p):
        raise DataError(
            '%s 是一個符號連結:%s\n'
            '  它指向:%s\n'
            '  跟著連結寫下去會動到資料夾外面的檔案,所以這裡直接停,一個位元組都不寫。\n'
            '  請把那個連結移走(或直接對真正的那個檔跑一次)之後再試。'
            % (what, p, os.path.realpath(p)))


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

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

    ── 2026-09-05 再修(唯讀稽核 🔴「可預測的 .part 會跟著符號連結走」)──────
    原本的暫存檔叫死名字 `<備份名>.part`,而且是用 shutil.copy2 寫的。
    那個名字**別人可以先佔住**:在同一個資料夾先放一個
    `<球場檔>.shrinkbak.part` 符號連結指到資料夾外面的某個檔,
    copy2 會沿著連結把外面那個檔先截成 0 再寫進去 ——
    後面的 os.replace 只換掉連結本身,但外面那個檔早就被蓋掉了。
    現在改成 tempfile.mkstemp(dir=備份要放的那個資料夾):名字是隨機的,
    而且是用 O_CREAT|O_EXCL 建出來的,佔不住也跟不到連結;
    換名之前多做一次 flush + fsync,確定內容真的落到磁碟。
    """
    # os.fspath 把 Path 與字串拉成同一種型別,下面的字串相加才不會爆掉。
    src, dst = os.fspath(src), os.fspath(dst)
    # 備份的名字是算出來的(正本 + 副檔名),沒辦法隨機 —— 所以改成「是連結就拒絕」。
    _refuse_if_symlink(dst, '備份要寫的位置')
    folder = os.path.dirname(os.path.abspath(dst)) or '.'
    # 暫存檔跟備份放同一個資料夾:os.replace 只有在同一個檔案系統上才是原子的。
    # 開頭那個點讓它在 macOS / Linux 的檔案總管裡是隱藏的,不會嚇到讀者。
    fd, part = tempfile.mkstemp(dir=folder, prefix='.' + os.path.basename(dst) + '.part-')
    try:
        with os.fdopen(fd, 'wb') as fdst:
            with open(src, 'rb') as fsrc:
                shutil.copyfileobj(fsrc, fdst)
            # 先把緩衝區推給作業系統,再要求它真的寫到碟上。
            fdst.flush()
            os.fsync(fdst.fileno())
        # copy2 = 複製內容 + 複製權限與時間,這一行補的是後半段。
        # 抄不到不該讓備份失敗(有些網路磁碟機/隨身碟不吃 chmod):
        # 那時候備份會沿用 mkstemp 給的 0600,比原檔更嚴,內容一樣是完整的。
        try:
            shutil.copystat(src, part)
        except OSError:
            pass
        # 換名這一步在同一個檔案系統上是原子的:要嘛整份就位,要嘛完全沒動。
        os.replace(part, dst)          # os.replace 是原子的
    except BaseException:
        # 收拾殘骸。這裡用 BaseException 而不是 Exception,是為了連
        # KeyboardInterrupt / SystemExit 都會走到 —— 按 Ctrl-C 一樣要清乾淨。
        try:
            os.remove(part)
        except OSError:
            pass
        # 清完照樣把原來那個例外丟出去,不可以吞掉:備份失敗必須讓上層知道。
        raise


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

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

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

    現在檢查五件事:
      1. 備份不是 0 bytes
      2. BIGF:檔頭第 4-8 個位元組宣告的總長度要等於實際長度
         (兩種位元組序都接受;哪些檔是大端、各有幾個,以 reference/bigf.html 量到的為準,這裡不寫會過期的數字)
      3. LOCH(語系檔):檔頭指到的 LOCL 要在檔內,而且最後一條字串的位移
         也要在檔內 —— 截斷之後那個位移一定會超出去
      4. MZ(執行檔):PE 節區表裡 raw offset + raw size 的最大值不得超過檔案長度
      5. **通用地板**:非 BIGF 的備份不得小於「要被蓋掉的那個檔」的一半。
         非 BIGF 的工具都是原地改(大小幾乎不變),所以這條很安全;
         BIGF 走 append 會越改越大,所以刻意**不套**這條,由第 2 道負責。
    """
    import struct
    bak, dst = os.fspath(bak), os.fspath(dst)
    # 第 0 道:備份根本不在,就沒有「還原」這回事,直接說清楚。
    if not os.path.exists(bak):
        raise SystemExit('找不到備份:%s' % bak)
    # 第 1 道:0 bytes。這是「備份到一半被中斷」最常見的樣子。
    n = os.path.getsize(bak)
    if n == 0:
        raise SystemExit(
            '備份是 0 bytes(多半是上次備份到一半被中斷),不敢拿它覆蓋 %s。' % dst)
    # 只讀開頭 8 個位元組就夠認出是哪一種檔:
    #   位移 0-4   四個字元的招牌(BIGF / LOCH / MZ 只有兩個字元)
    #   位移 4-8   BIGF 用來記「整個檔案有多長」的欄位
    with open(bak, 'rb') as _f:
        head = _f.read(8)

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

    # 第 2 道(封裝檔):檔頭 +0x04 的四個位元組寫著「這個檔應該有多長」。
    # 截斷之後那個數字不會跟著變小,所以一比就露餡。
    # ⚠️ 這一欄的位元組順序兩種都遇得到(實測本機 295 個 BIGF:288 小端、7 大端),
    #    所以兩種都算過,任一種對得上就放行。
    if len(head) == 8 and head[:4] == b'BIGF':
        le = struct.unpack('<I', head[4:8])[0]
        be = struct.unpack('>I', head[4:8])[0]
        if le != n and be != n:
            _stop('檔頭說它應該是 %d bytes(或 %d),實際只有 %d bytes。' % (le, be, n))
        return _do_copy(bak, dst)

    # 第 3 道(語系檔 .LOC):這種檔的結構是「檔頭 LOCH → 字串區 LOCL → 位移表 → 字串」。
    # 位移表在檔案中段、字串在最後面,所以截斷一定會讓「最後一條字串的位移」指到檔外。
    #   位移 16-20  小端 32 位元:LOCL 區塊從第幾個位元組開始(相對於檔頭那 16 bytes 之後)
    #   L+12~L+16   小端 32 位元:總共幾條字串
    #   L+16 起     每條 4 個位元組的位移,相對於 L
    if len(head) >= 4 and head[:4] == b'LOCH':
        try:
            d = open(bak, 'rb').read()
            L = struct.unpack('<I', d[16:20])[0]
            if L + 16 > n or d[L:L + 4] != b'LOCL':
                _stop('語系檔的字串區(LOCL)應該在位移 %d,那裡不是 LOCL。' % L)
            lcnt = struct.unpack('<I', d[L + 12:L + 16])[0]
            if lcnt <= 0 or L + 16 + lcnt * 4 > n:
                _stop('語系檔的位移表被截斷了(宣告 %d 條)。' % lcnt)
            last = struct.unpack('<I', d[L + 16 + (lcnt - 1) * 4:L + 20 + (lcnt - 1) * 4])[0]
            if L + last >= n:
                _stop('語系檔最後一條字串在位移 %d,超出檔案結尾(%d bytes)。'
                      % (L + last, n))
        except SystemExit:
            raise
        except (struct.error, IndexError):
            _stop('讀不出語系檔的結構,它壞了。')

    # 第 4 道(Windows 執行檔):PE 的節區表寫著每一段內容在檔案裡的起點與長度。
    # 把「起點 + 長度」取最大值,那就是這個檔至少該有多長 —— 被截斷就一定超出去。
    #   位移 0x3C   小端 32 位元:PE 檔頭在第幾個位元組
    #   pe+6        小端 16 位元:節區數
    #   pe+20       小端 16 位元:選用檔頭有多長(節區表接在它後面)
    #   節區表每筆 40 bytes,其中 +16 是 raw size、+20 是 raw offset
    if len(head) >= 2 and head[:2] == b'MZ':
        try:
            d = open(bak, 'rb').read()
            pe = struct.unpack('<I', d[0x3C:0x40])[0]
            if pe + 24 > n or d[pe:pe + 4] != b'PE\x00\x00':
                _stop('執行檔的 PE 檔頭不在它該在的地方,檔案不完整。')
            nsec = struct.unpack('<H', d[pe + 6:pe + 8])[0]
            optsz = struct.unpack('<H', d[pe + 20:pe + 22])[0]
            sec = pe + 24 + optsz
            end = 0
            for i in range(nsec):
                o = sec + i * 40
                if o + 40 > n:
                    _stop('執行檔的節區表被截斷了(宣告 %d 個節區)。' % nsec)
                raw_sz, raw_off = struct.unpack('<II', d[o + 16:o + 24])
                end = max(end, raw_off + raw_sz)
            if end > n:
                _stop('執行檔的節區指到 %d bytes,實際只有 %d bytes。' % (end, n))
        except SystemExit:
            raise
        except (struct.error, IndexError):
            _stop('讀不出執行檔的結構,它壞了。')

    # 通用地板 —— 非 BIGF 走到這裡
    # (第 5 道)上面三道都是「認得出格式才擋得住」,認不出來的格式一樣要有底線。
    # 非 BIGF 的工具都是原地改(大小幾乎不變),所以「備份不到正本的一半」
    # 一定是出事了。BIGF 走接到檔尾的改法會越改越大,刻意不套這一條。
    if os.path.exists(dst):
        live = os.path.getsize(dst)
        if live > 0 and n * 2 < live:
            _stop('備份只有 %d bytes,而要被蓋掉的那個檔有 %d bytes ——'
                  '差太多了(不到一半)。' % (n, live))
    return _do_copy(bak, dst)


# 記「正本到底被換掉了沒有」。按 Ctrl-C 的時候要照實說 ——
# 沒換過才可以說「一個位元組都沒有動到」,換過了就得告訴人家怎麼回去。
# (2026-09-05 加。原本不管有沒有寫過都印同一句「已中斷。」,
#  換到一半被打斷的人會以為自己的球場檔還是原來那一份。)
#
# ⚠️ 2026-09-06 改成**三態**。兩態不夠用:os.replace 做完、還沒登記之前
#    那一瞬間被 Ctrl-C 打斷,收尾看到的還是「沒動過」,就會對著一個
#    **已經被換掉的正本**說「一個位元組都沒有動到」。
#      idle       還沒動過
#      replacing  正在換(下面的 _NoInterrupt 裝得上的話幾乎看不到,它是保險)
#      replaced   換過了
#    'what' 記的是哪一種換名('shrink' 清孤兒 / 'restore' 從備份還原)。
#
# ⚠️ 2026-09-11 再修:**這三個欄位一律用一次 `.update()` 寫完,不可以分成兩行。**
#    三態本身是對的,但登記的動作原本是「先寫 what/path、下一行才寫 phase」——
#    Ctrl-C 落在那兩行之間的話,what 已經是 'shrink' 而 phase 還停在 'idle',
#    收尾就照著 what 說「檔案**已經**換成清過的那一版了」,而 os.replace 根本還沒跑。
#    (實測餌:exit 130、印出那句話,正本 sha256 一個位元組都沒有變 —— 就是 SPEC §1
#     點名要消滅的那種謊。撤銷登記那兩行同樣有縫,落在中間會印「正在替換 None」,
#     還附一行叫人去跑 --restore「None」的指令,照著做也做不出東西來。)
#    `dict.update` 是一個 C 函式呼叫,直譯器只在位元組碼之間才處理訊號,
#    所以三個欄位是一起換掉的,中間沒有可以插進去的那一格。
_REPLACED = {'what': None, 'path': None, 'phase': 'idle'}


class _NoInterrupt(object):
    """把「os.replace + 登記」包起來,這一段期間不讓 Ctrl-C 插隊。

    收到 SIGINT 先記在心裡,離開這一段之後再照常丟出 KeyboardInterrupt。
    這樣收尾看到的登記一定跟磁碟上的狀態一致,不會出現
    「檔案已經換掉了、程式還以為沒換」的那一瞬間。

    ⚠️ signal.signal 只有主執行緒裝得上。裝不上就退回原本的行為 ——
       不會比以前更糟,而且上面那個 'replacing' 狀態就是為這種情形留的:
       真的落在縫裡,收尾會誠實說「不確定換好了沒有」,不會說「沒動到」。
    """

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

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

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


def _do_copy(bak, dst):
    """真正把備份蓋回正本的地方 —— 而且是原子的(2026-09-05 改)。

    刻意獨立成一個函式:上面五道把關每一道都以「呼叫它」作為放行的動作,
    所以「哪裡會覆蓋正本」在這個檔裡只有一個出口,好稽核也好下餌測試。

    ── 為什麼不能只寫 shutil.copy2(bak, dst)(唯讀稽核 🔴 第 1 條)──────────
    copy2 的第一個動作是**把 dst 截成 0 bytes**,然後才一段一段把備份倒進去。
    那五道把關擋得住「備份是壞的」,擋不住「複製到一半停電、磁碟滿、外接碟被拔掉、
    按了 Ctrl-C」—— 那時候玩家的球場檔會停在 0 bytes 或半截,
    而他手上唯一的那份備份還在,卻已經沒有第二次機會了(第二次 --restore 一樣會先截斷)。

    現在的做法:
      1. 在**正本所在的那個資料夾**用 tempfile.mkstemp 開一個隨機名字的暫存檔
         (同一個檔案系統,os.replace 才會是原子的;名字隨機才佔不住、跟不到連結)
      2. 一邊寫一邊算 sha256,寫完 flush + fsync
      3. 把正本的權限抄到暫存檔(沒有這一步,還原完的檔會變成 umask 給的權限)
      4. **把暫存檔整份讀回來再算一次 sha256**,跟備份的雜湊與長度都要相等
      5. 全部對上才 os.replace 換名 —— 而且「換名 + 登記已經換過了」
         綁成一段不可中斷的區塊(見 _NoInterrupt),Ctrl-C 落不進那條縫裡
    任何一步失敗就把暫存檔刪掉,正本**一個位元組都沒被動過**。
    """
    bak, dst = os.fspath(bak), os.fspath(dst)
    # 這兩個名字都是使用者給的/算出來的,沒辦法隨機化 —— 是連結就拒絕。
    _refuse_if_symlink(dst, '要還原的目標')
    _refuse_if_symlink(bak, '備份')
    folder = os.path.dirname(os.path.abspath(dst)) or '.'
    fd, tmp = tempfile.mkstemp(dir=folder, prefix='.' + os.path.basename(dst) + '.restore-')
    # 進來之前的登記。複驗沒過時的自動還原也走這一支,那時候登記是「已經換成清過的那一版」;
    # 還原沒換成的話,要收回的是**這個樣子**,不是「沒動過」(見下面 except 那一段)。
    before = dict(_REPLACED)
    try:
        want = hashlib.sha256()
        with os.fdopen(fd, 'wb') as f_out:
            with open(bak, 'rb') as f_in:
                while True:
                    chunk = f_in.read(1024 * 1024)
                    if not chunk:
                        break
                    want.update(chunk)
                    f_out.write(chunk)
            f_out.flush()
            os.fsync(f_out.fileno())
        # 權限沿用「要被蓋掉的那個正本」。抄不到不該讓整件事失敗(例如正本已經不見了)。
        try:
            shutil.copymode(dst, tmp)
        except OSError:
            pass
        # 讀回來再算一次。拿記憶體裡剛算的那個去比自己,驗不出任何東西。
        got = hashlib.sha256()
        with open(tmp, 'rb') as f:
            while True:
                chunk = f.read(1024 * 1024)
                if not chunk:
                    break
                got.update(chunk)
        if os.path.getsize(tmp) != os.path.getsize(bak) or got.digest() != want.digest():
            raise DataError(
                '把備份複製出來的那一份跟備份本身對不起來 —— 沒有換上去,%s 原封不動。'
                % os.path.basename(dst))
        # ⚠️ 2026-09-06:「換名」跟「登記換過了」中間不可以有縫。原本是
        #    os.replace 一行、下一行才登記,Ctrl-C 剛好落在那兩行之間的話,
        #    收尾看到的登記還是「沒動過」—— 而正本已經是備份那一份了。
        #    現在先登記「正在換」,再把換名與登記一起包進 _NoInterrupt。
        # ⚠️ 2026-09-11 再修:登記本身也不可以分成兩行(見上面 _REPLACED 那一段)。
        #    原本 what/path 一行、phase 一行,Ctrl-C 落在中間會讓收尾說
        #    「還原那一步**已經**做完了」而其實還沒還原。改成一次 update。
        _REPLACED.update(what='restore', path=dst, phase='replacing')
        with _NoInterrupt():
            os.replace(tmp, dst)
            _REPLACED['phase'] = 'replaced'
    except BaseException as _e:
        # 失敗就只留下一個沒人看得懂的隱藏暫存檔,正本完好。
        try:
            os.remove(tmp)
        except OSError:
            pass
        # 只有在「確定沒換成」的時候才可以把「正在換」收回去。
        # os.replace 自己丟例外就是那種情形:它要嘛整個做完、要嘛完全沒做。
        # 撤銷一樣是一次寫完:分兩行寫的話,Ctrl-C 落在中間會留下
        # what=None 而 phase 還是 'replacing',收尾就印「正在替換 None」。
        # ⚠️ 2026-09-24 改:收回的是**進來之前的登記**,不是一律「還沒動」。
        #    直接跑 --restore 的時候兩者一樣;但複驗沒過、自動還原又沒換成的時候,
        #    正本已經是清過的那一版 —— 登記一旦被改成「還沒動」,
        #    接下來按一下 Ctrl-C,收尾就會說「你的遊戲檔一個位元組都沒有動到」。
        if _REPLACED['phase'] == 'replacing' and isinstance(_e, OSError):
            _REPLACED.update(before)
        raise




# Windows 的主控台預設不是 UTF-8,不轉的話這支腳本印的中文會變亂碼甚至直接丟例外。
# 包在 try 裡是因為舊版 Python 沒有 reconfigure —— 轉不了就照舊跑,不要為了排版而中止。
try:
    sys.stdout.reconfigure(encoding='utf-8')
    sys.stderr.reconfigure(encoding='utf-8')
except Exception:
    pass


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


# 三個上限。前兩個是**拿檔案自己宣稱的數字去配置記憶體之前的煞車** ——
# 壞掉的檔會宣稱「我解開有 4 GB」或「我有 20 億個項目」,照著做就是當場吃光記憶體。
# 數值取得比真實資料大很多:本站量過最大的解壓輸出遠低於 64 MB,
# 而封裝檔的目錄項目數實測從數百到一萬多都有(2026-09-05 掃本站測試機 680 個封裝檔,
# 最多的是 audio/cd/spch_pbp/pnamedat.big 的 12,528 筆)。
MAX_UNCOMPRESSED = 64 * 1024 * 1024
MAX_BIG_ENTRIES = 200000
# 備份的副檔名。每一課用不同的字尾,是為了讓「哪一支工具改過這個檔」一眼看得出來。
BACKUP_SUFFIX = '.shrinkbak'


# ─────────────────────────────────────────────────────────
#  QFS(EA 的壓縮格式,檔頭是 10 FB)
# ─────────────────────────────────────────────────────────
# ⚠️ 讀者先知道這件事比較好:**這一整段(QFS 兩支 + 下面的 FSH 三塊)
#    在這支腳本裡沒有任何地方呼叫**。它是本站腳本共用的一塊,
#    站上 30 支腳本裡有 13 支帶著 QFS、8 支帶著 FSH(換球衣、換大頭照、
#    改記分板顏色那幾課真的會用到)。這一課只搬位置、不解壓也不改圖,
#    所以用不到 —— 留著是為了讓每一支都是「一個檔就能跑」,
#    也讓讀者能拿同一份程式碼去改別的東西。
#    同理,檔頭的 `import re` 與 `import zlib` 在這一支也沒有用到。
def qfs_decompress(data):
    """把 EA 的 QFS(又叫 RefPack)壓縮資料解開,回傳原始位元組。

    格式重點(讀者從程式碼上看不出來的部分):
      · 招牌在**第 2 個位元組**是 0xFB,第 1 個位元組是旗標。不是 0xFB 就當它沒壓縮,原樣回傳。
      · 旗標的最低位是 1 → 檔頭 10 bytes,解壓後大小放在位移 6~10;
        是 0 → 檔頭 5 bytes,解壓後大小放在位移 2~5。
      · **那個大小是大端(big-endian)**,而且是 3 或 4 個位元組,不是常見的 4 位元組小端。
      · 之後是一連串控制碼,每一碼帶「先照抄幾個位元組」與
        「再往回幾個位元組、複製幾個」兩件事,四種長度的編碼由第一個位元組的大小決定。

    往回複製的來源可以跟正在寫的位置重疊(這是這類壓縮的重點),
    所以下面是一個位元組一個位元組抄,不能用切片一次搬。
    """
    if len(data) < 2 or data[1] != 0xFB:
        return data
    if data[0] & 0x01:
        if len(data) < 10:
            raise DataError('QFS 檔頭不完整')
        size = int.from_bytes(data[6:10], 'big'); pos = 10
    else:
        if len(data) < 5:
            raise DataError('QFS 檔頭不完整')
        size = int.from_bytes(data[2:5], 'big'); pos = 5
    if not 0 <= size <= MAX_UNCOMPRESSED:
        raise DataError('QFS 宣稱解壓尺寸異常:%d' % size)

    out = bytearray()
    end = len(data)

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

    # 主迴圈:一次讀一個控制碼。四個區間各是一種編碼長度,
    # 由控制碼的數值大小決定,所以判斷順序必須由大到小,不能調換。
    while pos < end:
        b0 = data[pos]
        # 0xFC~0xFF:結束碼。只帶「照抄 0~3 個位元組」,抄完整份就到底了。
        if b0 >= 0xFC:
            n = b0 & 0x03; pos += 1
            out += data[pos:pos + n]; break
        # 0xE0~0xFB:純照抄,一次 4~112 個位元組,沒有往回複製。
        if b0 >= 0xE0:
            n = ((b0 & 0x1F) << 2) + 4; pos += 1
            out += data[pos:pos + n]; pos += n; continue
        # 0xC0~0xDF:四位元組碼,能表達最長的往回距離與長度。
        # 位元被拆散在四個位元組裡(這是 RefPack 的省空間手法),所以看起來很亂。
        if b0 >= 0xC0:
            b1, b2, b3 = data[pos + 1], data[pos + 2], data[pos + 3]; pos += 4
            n = b0 & 0x03
            length = ((b0 & 0x0C) << 6) + b3 + 5
            offset = ((b0 & 0x10) << 12) + (b1 << 8) + b2 + 1
        # 0x80~0xBF:三位元組碼,中距離。
        elif b0 >= 0x80:
            b1, b2 = data[pos + 1], data[pos + 2]; pos += 3
            n = (b1 >> 6) & 0x03
            length = (b0 & 0x3F) + 4
            offset = ((b1 & 0x3F) << 8) + b2 + 1
        # 0x00~0x7F:兩位元組碼,短距離,最常出現。
        else:
            b1 = data[pos + 1]; pos += 2
            n = b0 & 0x03
            length = ((b0 & 0x1C) >> 2) + 3
            offset = ((b0 & 0x60) << 3) + b1 + 1
        # 三種都是同一個順序:先照抄 n 個位元組,再往回 offset 複製 length 個。
        out += data[pos:pos + n]; pos += n
        copy_back(offset, length)
    # 截到檔頭宣稱的長度為止。但是多出來的那幾個位元組不會是「最後一次往回複製」
    # 造成的:往回複製的收尾就在上面 copy_back 裡,一超過宣稱的大小就當成壞檔擋下來,
    # 根本走不到這一行。真正沒被那道檢查看到的只有兩條路:結束碼帶的那 0 到 3 個
    # 照抄位元組,以及純照抄那一段(不過只要後面還有一次往回複製就會被擋下來)。
    # 2026-09-03 實測:餵一份宣稱 6 個位元組的資料流進來,用往回複製多吐 1 個,
    # 拿到的是「QFS 解出來的資料超過檔頭宣稱的 6 位元組」這個錯誤;改成用結束碼的
    # 尾巴多吐 1 個,才真的被這一行切掉,拿回 6 個位元組。至於正常的遊戲檔,
    # 拿本站測試機 MVP2026/data/frontend/ 底下 98 個封裝檔裡的 9,643 個 QFS 項目
    # 各解一次,沒有一項解出來的長度超過檔頭宣稱的大小,所以這一行在實務上
    # 是最後一道保險,不是這支程式的正常路徑。
    return bytes(out[:size])


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

    壓出來比 EA 原本的大(大約等於原始大小),但因為我們是接到檔尾,
    大一點沒有影響。換來的是速度快上千倍,而且不可能壓錯。
    """
    n = len(data)
    # 5 個位元組的短檔頭:0x10 是旗標(最低位 0 = 短檔頭),0xFB 是招牌,
    # 後面三個位元組是「解開有多長」,**大端**。
    out = bytearray([0x10, 0xFB, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF])
    # 純照抄的控制碼一次只能帶 4 的倍數(4~112),所以先把尾巴那 0~3 個位元組留給結束碼。
    tail = n % 4
    body = n - tail
    pos = 0
    while pos < body:
        chunk = min(112, body - pos)
        # 0xE0 開頭的照抄碼:低 5 位存的是「幾個 4 位元組」減 1,所以要先減 4 再除 4。
        out.append(0xE0 | ((chunk - 4) // 4))
        out += data[pos:pos + chunk]
        pos += chunk
    # 結束碼順便把剩下那 0~3 個位元組帶走。
    out.append(0xFC | tail)
    out += data[pos:]
    return bytes(out)


# ─────────────────────────────────────────────────────────
#  BIGF 封裝檔:讀目錄、接到檔尾
# ─────────────────────────────────────────────────────────
# 這一段開始才是這一課真正在用的東西。封裝檔的長相:
#
#     位移 0x00   'BIGF'      四個字元的招牌
#     位移 0x04   4 bytes     整個檔案有多長(**小端或大端都遇得到**,見 size_field_order)
#     位移 0x08   4 bytes     目錄裡有幾個項目(**大端**)
#     位移 0x0C   4 bytes     跟目錄長度有關的欄位;本課不動目錄長度,所以不碰它
#     位移 0x10   目錄開始    每一筆:4 bytes 起點 + 4 bytes 長度(兩個都是**大端**)
#                             接一個以 \x00 結尾的名字,長度不固定
#     目錄之後    才是各項目的內容
#
# 這一課要做的事,用這張圖講就是一句話:
# **把「內容」那一區照目錄的順序重排、擠掉沒人指的位元組,再把目錄裡的起點改成新位置。**
def size_field_order(path):
    """檔頭 +0x04 的「檔案總大小」是 little 還是 big endian。

    ⚠️ 這一欄兩種順序都遇得到,不能寫死,也不能照檔名或檔案大小猜。
       哪個檔是哪一種不是這個格式天生的性質,而是看你手上這一份被誰重新打包過。

       本站以檔頭的 BIGF 招牌(不是副檔名)認過四份 data 資料夾:
       三份剛安裝好的原版(英文版 207 個封裝檔、繁體中文版 205 個、PK 版 205 個)
       全部是 little-endian,一個 big-endian 都沒有;連那三份裡最大的兩個封裝檔
       models.big(172,992,803 個位元組)與 frontend/portrait.big
       (109,291,217 個位元組)也是 little-endian,所以「大檔就是 big-endian」是假的。
       只有本站測試機那份疊過模組的 data 資料夾(384 個封裝檔)量得到 big-endian,
       共 10 個檔、7 個檔名:models.big、frontend/portrait.big、
       audio 底下的 pnamedat.big 與 pnamehdr.big,以及球場夜間檔
       coornite.big / dodgnite.big / wrignite.big
       (夜間那三個在測試機上另有一份複本放在別的球場資料夾,所以檔數比檔名多 3 個)。
       這 7 個檔名在三份原版裡量到的通通是 little-endian。

       所以不要照檔名或檔案大小記:讀出來是哪一種,就照哪一種寫回去。
    """
    # 判法很土但很可靠:用兩種順序各解一次,哪一種等於實際檔案大小就是哪一種。
    # 不必查表、不必知道是哪個檔,而且兩種都不對時等於順便驗出「這個檔壞了」。
    n = os.path.getsize(path)
    with open(path, 'rb') as f:
        raw = f.read(8)
    if len(raw) < 8:
        raise DataError('%s 太小,不像封裝檔' % os.path.basename(path))
    if struct.unpack('<I', raw[4:8])[0] == n:
        return '<'
    if struct.unpack('>I', raw[4:8])[0] == n:
        return '>'
    raise DataError('%s 的檔頭大小欄位跟實際檔案大小對不上 —— 這個檔可能已經損毀'
                    % os.path.basename(path))


def _existing_backup_problem(path):
    """既有的備份能不能信。可以信回 None;壞掉回一句話說哪裡不對。

    2026-10-04(上線前稽核找到的舊問題):--apply 看到旁邊已經有備份,就印「備份已存在,保留最早那一份」往下做 ——
    以前**完全沒看那份備份長什麼樣**。2026-08-29 之前的舊版備份不是原子的(直接 copy2),複製途中斷電 / 外接碟拔掉會留下半截檔;
    那種檔還活在某些讀者的資料夾裡。把它當成「最早那一份」留著,後果是:球場檔被清了、真正的原樣再也回不去,而且之後 --restore
    會因為備份被截斷而拒絕(拒絕是對的,但讀者以為自己有退路)。所以:有備份就先驗,驗不過就在動任何檔之前停下來。
    檢查跟 _restore_from_backup 對 BIGF 的那兩道一樣(不是 0 bytes、檔頭宣告的總長度等於實際長度,兩種位元組序都接受),
    再加上「開頭要是 BIGF」—— 這支工具備份的永遠是 BIGF 封裝檔。
    """
    n = os.path.getsize(path)
    if n == 0:
        return '它是 0 位元組'
    with open(path, 'rb') as f:
        head = f.read(8)
    if len(head) < 8 or head[:4] != b'BIGF':
        return '它的開頭不是 BIGF,不像是這支工具備份出來的封裝檔'
    le = struct.unpack('<I', head[4:8])[0]
    be = struct.unpack('>I', head[4:8])[0]
    if le != n and be != n:
        return '檔頭說它應該有 %d 位元組(或 %d),實際只有 %d 位元組 —— 被截斷過' % (le, be, n)
    return None


def _refuse_bad_existing_backup(backup):
    """旁邊已經有備份時,先驗它;壞的就停下來(此時球場檔與備份都還沒被碰)。"""
    why = _existing_backup_problem(backup)
    if why is None:
        return
    raise DataError('旁邊已經有一份備份 %s,但它是壞的:%s。\n'
                    '  多半是舊版工具備份到一半被中斷(磁碟滿、外接碟拔掉、按了 Ctrl-C)留下的半截檔。\n'
                    '  這一次先停下來,球場檔與備份都沒有動 —— 不然這份壞備份會被當成「最早那一份」留著,\n'
                    '  球場檔清完之後再也回不到原樣,而且之後 --restore 也會因為備份被截斷而拒絕。\n'
                    '  怎麼辦:\n'
                    '    · 如果你從來沒有用這支工具清過這個球場檔:把壞備份改個名字或移走(例如在檔名後面加 .壞),\n'
                    '      再跑一次;這次會用現在的 %s 重新做一份好備份。\n'
                    '    · 如果已經清過,現在的 %s 就不是原樣了 —— 請改用你自己另外留的整個遊戲資料夾備份。'
                    % (os.path.basename(backup), why, os.path.basename(backup)[:-len(BACKUP_SUFFIX)],
                       os.path.basename(backup)[:-len(BACKUP_SUFFIX)]))


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

    目錄欄位位置留著,是為了之後只改那 8 個位元組,不必重寫整個目錄。

    名字的長度不固定(以一個 0x00 位元組結尾),所以**沒辦法直接算出目錄有多長** ——
    只能從第 16 個位元組開始一筆一筆往下走。

    ⚠️ 解析不出宣告的筆數就丟 DataError,不回傳半份目錄(2026-09-05 加)。
       下面那一大塊只讀「檔頭 + count*80 + 8 KB」;名字平均超過約 71 個字元時,
       目錄會超出這一塊,迴圈就會停在中間。原本停下來之後照樣回傳已讀到的那些,
       而少解析到的那幾筆會被 analyse() 當成「沒人指到的位元組」清掉 ——
       複驗又是同一個函式重讀的、一樣少那幾筆,於是一個被清壞的檔會拿到綠燈。
       (實測合成餌:2000 筆、名字 90 字元,只解析出 1557 筆,--apply 之後
        443 筆的內容真的沒了,而腳本印「1557 個項目全部逐位元組相同 ✅」。)
    """
    try:
        with open(path, 'rb') as f:
            head = f.read(16)
            if len(head) < 16 or head[:4] != b'BIGF':
                raise DataError('%s 的開頭不是 BIGF,這不是封裝檔' % os.path.basename(path))
            # 項目數在位移 8,大端。先用它擋掉離譜的檔,再拿它決定要讀多少。
            count = int.from_bytes(head[8:12], 'big')
            if not 0 < count < MAX_BIG_ENTRIES:
                raise DataError('%s 的目錄項目數異常(%d)' % (os.path.basename(path), count))
            # 一次讀一大塊,不要為了每一筆各讀一次檔(名字長度不固定,反覆 seek 很慢)。
            # 每筆抓 80 bytes 是寬鬆的估計(8 bytes 欄位 + 名字),再加 8 KB 緩衝。
            blob = head + f.read(count * 80 + 8192)
    except OSError as e:
        raise DataError('讀不到 %s:%s' % (path, e))

    items = []
    pos = 16
    for _ in range(count):
        if pos + 8 > len(blob):
            break                                   # 目錄比預估長,已讀到的就夠用
        # field 記的是「這一筆的 8 個位元組在檔案裡的位置」。
        # 之後要改起點時只覆寫這 8 個位元組,目錄的長度與名字完全不動。
        field = pos
        off = int.from_bytes(blob[pos:pos + 4], 'big')
        size = int.from_bytes(blob[pos + 4:pos + 8], 'big')
        pos += 8
        # 名字以 \x00 結尾。找不到結尾就是讀到的那一塊不夠長,停在這裡。
        end = blob.find(b'\x00', pos)
        if end < 0:
            break
        # 用 latin-1 解碼:這裡只要「一個位元組換一個字元、不會丟例外」,
        # 名字純粹拿來顯示與配對,不需要真的懂它是什麼編碼。
        items.append((blob[pos:end].decode('latin-1', 'replace'), field, off, size))
        pos = end + 1
    # 上面那兩個 break 的意思是「我讀進來的那一塊不夠長」,不是「目錄到此為止」。
    # 少一筆就不敢動這個檔 —— 見上面 docstring 那段實測。
    if len(items) != count:
        raise DataError(
            '%s 的目錄宣告 %d 筆,只解析得出 %d 筆 —— 名字可能比預估的長,不敢動這個檔。'
            % (os.path.basename(path), count, len(items)))
    return items


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

    讀完一定要驗長度:目錄說有 N 個位元組而只讀到 M 個,代表這個檔被截斷過。
    這種情況要當場停下來,不可以拿短少的資料繼續做事。

    (共用區塊。這一課整份讀進記憶體再切,所以沒有呼叫它。)
    """
    with open(path, 'rb') as f:
        f.seek(off)
        data = f.read(size)
    if len(data) != size:
        raise DataError('目錄說這一項有 %d 個位元組,實際只讀到 %d' % (size, len(data)))
    return data


# ⚠️ 2026-09-24:要抄下面這一支去改別的封裝檔之前,先看這一段。
#    它是**原地追加的舊寫法**:直接對你給的那個檔用 'r+b' 往檔尾接資料、再改目錄
#    8 bytes、再改檔頭 4 bytes。三個動作中間被打斷(斷電、外接碟被拔掉)的話,
#    那個檔會停在「資料接上去了、目錄或檔頭還沒改完」的樣子:檔頭寫的總長度
#    跟實際大小對不上,下一次連 size_field_order 都過不了,只能還原。
#    本站「把一套球衣裝進遊戲」「換球員大頭照」「做一張新的球員臉皮」「換球隊隊徽」
#    「一整包模組自動歸位」這幾課真正在用的,已經是另一種寫法:
#    先把封裝檔複製一份工作複本到同一個資料夾 → 接資料、改目錄、改檔頭全做在複本上
#    → 讀回來驗過 → 最後一次原子換名換上去。中途出事,原本那個檔一個位元組都沒被寫過。
#    範本在 tutorials/install-uniform/mvp_install_uniform.py:
#    _stage_copy()(做工作複本)與 cmd_install() 換名那一段。
#    這一支留在這裡,是為了用程式碼講清楚「孤兒資料從哪裡來」(見下面的說明);
#    這一課自己沒有呼叫它。
def append_entry(path, field_pos, blob):
    """把 blob 接到檔尾,只改該項目的目錄 8 bytes 與檔頭的 4 bytes。

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

    **這正是孤兒資料的來源**,也就是這一課要清的東西:
    舊的那一份還躺在原地,只是沒有人再指向它。這裡把它寫成程式碼,
    是為了讓讀者看清楚「安全的改法」跟「檔案越變越大」其實是同一件事的兩面。

    (共用區塊。這一課是重排整個檔,不走接到檔尾這條路,所以沒有呼叫它。
     這是原地追加的舊寫法,要拿去真的改檔請看上面那段註解指的範本。)
    """
    order = size_field_order(path)          # 一定要在改檔案之前先量
    # 新資料的起點就是現在的檔尾。先問大小,再開檔寫。
    new_off = os.path.getsize(path)
    with open(path, 'r+b') as f:
        f.seek(0, os.SEEK_END)
        f.write(blob)
        total = f.tell()
        # 只覆寫這一項目錄的那 8 個位元組:前 4 是新起點,後 4 是新長度。
        f.seek(field_pos)
        f.write(struct.pack('>II', new_off, len(blob)))     # 目錄一律 big-endian
        # 再更新檔頭 +0x04 的「整個檔案有多長」。位元組順序沿用原檔那一種。
        f.seek(4)
        f.write(struct.pack(order + 'I', total))
        # fsync 把資料真的推到磁碟。少了這一步,寫完到斷電之間那段時間,
        # 檔案可能停在「目錄已經指到新位置、但新資料還沒落地」的狀態。
        f.flush()
        os.fsync(f.fileno())
    return new_off


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


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

    SHPI 容器的長相(**這裡全部是小端**,跟上面 BIGF 的目錄相反):
      位移 0x00   'SHPI'
      位移 0x08   4 bytes   裡面有幾張圖
      位移 0x10   起  目錄:每筆 8 bytes = 4 bytes 標籤 + 4 bytes 該圖的起點
                      所以第一張圖的起點在位移 16 + 0*8 + 4 = 20
      圖片記錄     +0    1 byte    格式代號(查 FSH_FORMATS)
                   +1    3 bytes   這個區塊有多長(**三個位元組**,小端,不是四個)
                   +4    2 bytes   寬
                   +6    2 bytes   高
                   +16   像素資料開始

    (共用區塊。這一課不碰圖片,所以沒有呼叫它。)
    """
    if len(data) < 16 or data[:4] != b'SHPI':
        raise DataError('不是 SHPI 檔(開頭是 %r)' % data[:4])
    num = struct.unpack_from('<I', data, 8)[0]
    if num < 1:
        raise DataError('這個 SHPI 裡一張圖都沒有')
    off = struct.unpack_from('<I', data, 20)[0]         # 16 + 0*8 + 4
    if off + 16 > len(data):
        raise DataError('圖片記錄的檔頭不完整')
    # 格式代號不在表裡就停下來。**不要猜**:猜錯會算出錯的每像素位元組數,
    # 接著就是把別人的圖片資料當成自己的覆蓋掉。
    code = data[off]
    if code not in FSH_FORMATS:
        raise DataError('沒見過的格式代號 0x%02X' % code)
    block_size = data[off + 1] | (data[off + 2] << 8) | (data[off + 3] << 16)
    width = struct.unpack_from('<H', data, off + 4)[0]
    height = struct.unpack_from('<H', data, off + 6)[0]
    # 4096 是寬鬆的上限,用來擋掉「讀到的根本不是尺寸」的情況。
    if not (0 < width <= 4096 and 0 < height <= 4096):
        raise DataError('圖片尺寸異常 %dx%d' % (width, height))
    start = off + 16
    # 像素資料到哪裡結束,三種來源由可靠到不可靠:
    #   ① 區塊自己寫了長度(> 16 才算數,16 是檔頭本身的長度)
    #   ② 沒寫的話,用下一張圖的起點當作結束點
    #   ③ 只有一張圖又沒寫長度,那就到檔尾
    if block_size > 16:
        end = min(off + block_size, len(data))
    elif num > 1:
        end = min(struct.unpack_from('<I', data, 16 + 8 + 4)[0], len(data))
    else:
        end = len(data)
    return code, width, height, start, end


def fsh_replace_pixels(data, start, end, new_pixels):
    """把 start~end 這一段像素換成 new_pixels,長度必須完全一樣。

    **長度一樣是唯一的放行條件,而且不可以放寬。** 這個容器裡每一張圖的
    起點都寫在前面的目錄裡;像素區長度一變,後面每一張圖的起點就全錯了,
    而錯掉的檔案在遊戲裡不是報錯,是花掉或直接跳出。

    (共用區塊。這一課不碰圖片,所以沒有呼叫它。)
    """
    if len(new_pixels) != end - start:
        raise DataError('新像素有 %d 個位元組,原本是 %d —— 尺寸或格式不一致,拒絕寫入'
                        % (len(new_pixels), end - start))
    return data[:start] + new_pixels + data[end:]




# ─────────────────────────────────────────────────────────
#  量:目錄指到多少、孤兒有多少
# ─────────────────────────────────────────────────────────
ALIGN = 4          # 實測:原版與社群包的每一個項目位置都是 4 的倍數


def analyse(path):
    """回傳 (檔案大小, 目錄項目, 目錄指到的位元組數, 第一筆資料的位置)。

    整支腳本的量法就這三行:**把目錄裡每一項的長度加起來,拿去跟檔案大小比。**
    差額就是「沒有人指到的位元組」。這個量法對任何一個封裝檔都成立,不只球場。

    `first`(最小的那個起點)之所以要一起回傳,是因為重排時要把
    檔頭與目錄那一整段原樣留著 —— 新的內容從原本第一筆資料的位置開始排。
    """
    items = big_entries(path)
    if not items:
        raise DataError('%s 的目錄是空的。' % os.path.basename(path))
    total = os.path.getsize(path)
    used = sum(it[3] for it in items)
    first = min(it[2] for it in items)
    # 動手之前先確認目錄自己是自洽的:有任何一項指到檔案外面,
    # 就代表這個檔已經壞了(或不是我們以為的格式)。這種檔一個位元組都不要碰。
    for name, field, off, size in items:
        if off + size > total:
            raise DataError('目錄裡「%s」指到 %d~%d,超出檔案大小 %d —— 這個檔可能已損毀,不敢動。'
                            % (name, off, off + size, total))
    return total, items, used, first


def planned_size(items, first):
    """重排之後這個檔會有多長。

    **刻意跟 `cmd_shrink` 的重排迴圈算同一件事**:從 first 開始,每一項先補 0
    對齊到 4 的倍數再放進去。兩邊算法一致,診斷印的數字才會等於 --apply 真的做出來的
    大小,「不用清」的判斷也才不會一邊說要清、一邊說不用清(2026-09-05 加)。
    """
    pos = first
    for _, _, _, size in sorted(items, key=lambda it: it[2]):
        pos += (-pos) % ALIGN
        pos += size
    return pos


def cmd_check(path):
    """不加參數時走這裡:**純唯讀的診斷**,印出這個檔的體檢報告。

    這是每一位讀者的第一個動作,也是這一課的重點 ——
    先弄清楚「檔案大」的原因是三種裡的哪一種,再決定要不要清。
    這個函式從頭到尾沒有任何寫檔的動作。
    """
    total, items, used, first = analyse(path)
    orphan = total - used
    # 整份讀進來,是為了下面判斷「大項目是不是壓縮過的」時可以直接看那兩個位元組。
    with open(path, 'rb') as _f:
        raw0 = _f.read()
    print('  檔案 %s' % os.path.basename(path))
    print('  大小            %12d 位元組(%.2f MB)' % (total, total / 1048576.0))
    print('  目錄項目數      %12d' % len(items))
    print('  目錄指到的內容  %12d 位元組(%.2f MB)' % (used, used / 1048576.0))
    print('  沒人指到的      %12d 位元組(%.2f MB,佔 %.1f%%)'
          % (orphan, orphan / 1048576.0, 100.0 * orphan / total))
    print()
    # 封裝檔變大有**三個**完全不同的原因,這裡直接判給使用者看:
    #   ① 孤兒堆積     —— 清得掉,畫面完全不變
    #   ② 貼圖真的變大 —— 一個位元組都清不掉
    #   ③ 壓縮被丟掉   —— 也清不掉,要重新壓縮才會小回去
    # 實測:rfksnite.big 14.95 MB 的孤兒是 0%,那 15 MB 全是真內容(②);
    # 而某個護具模組的 initstaz.big 是 2.94 MB、原版同一個檔只有 1.16 MB,
    # 孤兒同樣接近 0 —— 差別在原版的內層是壓縮的,那個模組沒壓(③)。
    # 只講①會誤導人,只講①②一樣會。
    # 先算「清完會變多小」:檔頭與目錄那一段照留(first),之後每一項的**起點**
    # 對齊到 4 的倍數再一項一項排下去 —— 跟 cmd_shrink 的重排迴圈同一套算法
    # (見 planned_size),所以這裡印的數字就是 --apply 真的會做出來的大小。
    newsize = planned_size(items, first)
    # 2% 這條線是拿來分岔用的,不是物理常數:低於它就算清了也看不出差別,
    # 那時該講的是「你的問題不是孤兒」,而不是「來清吧」。
    #
    # 另外兩個條件是 2026-09-05 補的,為的是讓這裡跟 cmd_shrink 說同一句話:
    #   · orphan < 1024:cmd_shrink 用的就是這條線。少了它,894 bytes 的
    #     data/frontend/misc.big(孤兒 128 bytes、14.3%)會在這裡被叫去清,
    #     而 --shrink 回他「幾乎沒有孤兒資料,不用清」——讀者查不到自己看到的字。
    #   · newsize >= total:孤兒排在第一筆資料之前,清不掉(見下面那一段)。
    # 這三個條件是 cmd_shrink 拒絕情況的**超集合**,所以「這裡叫你清、那裡不清」
    # 不會再發生。
    if orphan < 1024 or orphan < total * 0.02 or newsize >= total:
        # 判斷的順序照「哪一句對讀者最有用」排,不是照條件寫的順序:
        # 孤兒本來就少的檔,該講的還是「這個檔很乾淨」(原本就是這一句,沒動),
        # 另外兩句是 2026-09-05 新增的兩種情況才會看到。
        if orphan < total * 0.02:
            print('  這個檔很乾淨(孤兒不到 2%),清了也省不了什麼。')
        elif orphan < 1024:
            print('  沒人指到的只有 %d 個位元組,清了也省不了什麼。' % orphan)
        else:
            print('  重排之後不會變小 —— 那些沒人指到的位元組排在第一筆資料之前,')
            print('  跟檔頭與目錄一起被原樣留著,這一課清不掉。這個檔不用清。')
        # ③ 的線索:大項目的開頭不是 QFS 的 10 FB
        uncomp = [n for n, _, o, sz in items
                  if sz > 65536 and raw0[o:o + 2] != b'\x10\xfb']
        if uncomp:
            print()
            print('  ⚠️ 有 %d 個大項目**不是**壓縮格式開頭(不是 10 FB)。' % len(uncomp))
            print('     如果原版的同一個檔明顯比較小,那多半是存回去時沒有重新壓縮 ——')
            print('     那種情況這一課也幫不上忙,要重新壓縮才會小回去。')
            print('     例:%s' % '、'.join(uncomp[:3]))
        # 這句話只有在「孤兒真的很少」時才成立。孤兒很多但清不掉(newsize >= total)
        # 的檔也會走到這個分支,對那種檔說「做這個模組的人好好重新打包過」是錯的,
        # 所以這裡多綁一個 orphan 的條件(2026-09-05 加)。
        if total > 10 * 1048576 and not uncomp and orphan < total * 0.02:
            print()
            print('  ⚠️ 但它有 %.2f MB —— 這是「內容真的有那麼多」,不是垃圾。' % (total / 1048576.0))
            print('     做這個模組的人好好重新打包過,只是換上了更大的貼圖。')
            print('     這一課幫不了你。要縮小只能換回原版的那一份。')
    else:
        print('  清掉之後大約會變成 %.2f MB(現在 %.2f MB,省 %.0f%%)'
              % (newsize / 1048576.0, total / 1048576.0, 100.0 * (1 - newsize / float(total))))
        if newsize > 10 * 1048576:
            print('  ⚠️ 注意:清完還是有 %.2f MB —— 剩下的是真內容,清不掉了。'
                  % (newsize / 1048576.0))
        # 2026-08-28 修:這裡原本寫「把 --check 換成 --shrink」,
        # 但 --check 這個參數根本不存在(不加參數就是診斷),
        # 使用者照著做會打出一個無效的指令。改成印出可以直接複製的整行。
        print()
        print('  要看清完會變怎樣(不會動到檔案):')
        print('    python3 -I %s "%s" --shrink' % (os.path.basename(__file__), path))
        print('  確定要清:')
        print('    python3 -I %s "%s" --apply' % (os.path.basename(__file__), path))


def _verify_pack(check_path, items, raw):
    """重讀 check_path 的目錄,逐項跟「動手前的原始位元組」比對,回傳問題清單。

    回傳空的清單 = 全部對得上。**刻意回傳字串而不是直接印**:
    這個函式會被呼叫兩次,兩次要做的事不一樣 ——
      · 換名**之前**驗暫存檔:發現問題就整個放棄,一個字都不必印給讀者看,
        因為那時候遊戲檔一個位元組都還沒被動到
      · 換名**之後**驗正本:每一條都要印出來,然後自動從備份還原
    (2026-09-06 抽出來。原本只有換名之後那一次 —— 重排如果算錯,
     讀者的球場檔會先被換成壞的,再靠還原救回來;現在壞的那一份根本不會換上去。)

    複驗刻意**重新從磁碟讀一次目錄**,不拿記憶體裡的 out 來比 ——
    拿自己剛算出來的東西驗自己,驗不出任何東西。
    """
    probs = []
    items2 = big_entries(check_path)
    if len(items2) != len(items):
        probs.append('  ❌ 項目數變了(%d → %d)' % (len(items), len(items2)))
        return probs
    # 用「這一筆在目錄裡的位置」配對,不要用名字。
    # ⚠️ 名字在真實的封裝檔裡會重複:2026-09-05 掃本站測試機 680 個封裝檔,
    #    34 個有重複名字(frontend 底下的 coopteam.big、stadiums.big、
    #    coopunis.big …)。用名字當鍵的話,重複的那幾筆只有最後一筆留在字典裡,
    #    複驗就會拿另一筆的舊內容去比第一筆,把一個**寫得完全正確**的檔判成失敗、
    #    印 ❌ 叫人立刻 --restore —— 等於叫人把好檔換回肥檔。
    #    (合成餌實測:dup.fsh=AAA / other.fsh=BBB / dup.fsh=CCC,新檔三筆確實是
    #     AAA/BBB/CCC 位置與內容全對,舊版照樣印「dup.fsh 的內容跟原本不同」。)
    # 目錄的位置是唯一的,而且這一課從頭到尾沒動過目錄的長度與名字,
    # 所以新舊兩邊的 field 一定對得起來;名字有沒有被動到則另外驗一次。
    old_by_field = {it[1]: (it[0], it[2], it[3]) for it in items}
    with open(check_path, 'rb') as f:
        for name, field, off, size in items2:
            if field not in old_by_field:
                probs.append('  ❌ 新檔的目錄裡多出一筆(第 %d 個位元組那一筆,%s)'
                             % (field, name)); continue
            o_name, o_off, o_size = old_by_field[field]
            if name != o_name:
                probs.append('  ❌ 目錄第 %d 個位元組那一筆的名字變了(%s → %s)'
                             % (field, o_name, name)); continue
            if size != o_size:
                probs.append('  ❌ %s 的長度變了(%d → %d)' % (name, o_size, size)); continue
            # 逐位元組比對:這一項的內容,必須等於**動手前**同一項的內容。
            f.seek(off)
            if f.read(size) != bytes(raw[o_off:o_off + o_size]):
                probs.append('  ❌ %s 的內容跟原本不同' % name)
    # 最後補驗檔頭的大小欄。它跟實際檔案大小對不上的話,遊戲讀這個檔就會出事。
    n = os.path.getsize(check_path)
    with open(check_path, 'rb') as f:
        h = f.read(8)
    if struct.unpack('<I', h[4:8])[0] != n and struct.unpack('>I', h[4:8])[0] != n:
        probs.append('  ❌ 檔頭的大小欄跟實際檔案大小對不上')
    return probs


def cmd_shrink(path, apply_it):
    """--shrink / --apply 走這裡。

    `apply_it` 是 False 就只算給你看,一個位元組都不寫;
    是 True 才依序做:備份 → 寫暫存 → **複驗暫存** → 換名 → 再複驗一次。
    (2026-09-06 補上中間那一次:換名之前就發現重排算錯的話,
     壞的那一份根本不會換上去,遊戲檔一個位元組都沒有動。)
    兩條路走的是**同一段重排程式碼**,所以你預覽看到的數字就是實際會發生的事。
    """
    total, items, used, first = analyse(path)
    orphan = total - used
    print('  檔案 %s:%.2f MB,其中沒人指到的 %.2f MB(%.1f%%)'
          % (os.path.basename(path), total / 1048576.0, orphan / 1048576.0, 100.0 * orphan / total))
    # 1 KB 以下不值得動。動一個檔的風險是固定的,省下來的好處卻趨近於零。
    if orphan < 1024:
        print('  幾乎沒有孤兒資料,不用清。')
        return

    # 整份讀進記憶體。球場檔最大也就十幾 MB,這樣做最單純,
    # 而且待會複驗要拿「動手前的原始位元組」來比對,本來就得留著。
    with open(path, 'rb') as f:
        raw = bytearray(f.read())

    # ── 重排 ────────────────────────────────────────────
    # 原則:**只改每一項在檔案裡的位置**。
    #   · 項目順序照原本的先後(依原 offset 排序),不重新命名也不排序
    #   · 目錄的位元組長度完全不變(名稱與數量都沒動),所以檔頭的
    #     +0x0C 那一欄不必碰 —— 它跟目錄長度有關,而目錄長度沒變
    #   · 資料起點沿用原本第一筆的位置,把檔頭與目錄那一段整個留著
    #   · 每一項對齊到 4 的倍數(實測原版與社群包都是這樣)
    # 依原本的起點排序 = 維持原來的先後。**不重新命名、不按名字排序** ——
    # 順序換掉就等於在做「重新打包」,那是本站鐵律不准的事。
    order = sorted(items, key=lambda it: it[2])
    # 先把檔頭與整份目錄原樣搬過來(raw[:first]),新的內容從原本第一筆的位置開始排。
    out = bytearray(raw[:first])
    newpos = {}
    for name, field, off, size in order:
        # 補 0 補到 4 的倍數。這是實測原版與社群包都遵守的排列方式,
        # 補的是位置不是內容 —— 項目本身的位元組一個都沒動。
        while len(out) % ALIGN:
            out.append(0)
        newpos[field] = len(out)
        out += raw[off:off + size]

    # 內容排好之後才回頭改目錄。每一筆只覆寫它自己的 8 個位元組(新起點 + 原長度),
    # 目錄的總長度完全不變,所以檔頭 +0x0C 那一欄不必碰。
    for name, field, off, size in order:
        struct.pack_into('>II', out, field, newpos[field], size)

    # 最後更新檔頭 +0x04 的「整個檔案有多長」,位元組順序沿用原檔那一種。
    order_char = size_field_order(path)          # 這一欄兩種位元組順序都遇得到
    struct.pack_into(order_char + 'I', out, 4, len(out))

    # ⚠️ 孤兒不等於清得掉。上面 out 是從 raw[:first] 開始接的 ——
    #    排在第一筆資料起點之前的那一段(檔頭、目錄,以及夾在中間沒人指的位元組)
    #    原樣留著。孤兒全在那一段的話,重排完只會一樣大甚至更大。
    #    實測 data/frontend/igonly.big:孤兒 95.8%,--apply 之後
    #    5,456,802 → 5,456,803 個位元組(多 1 個),而且照樣做了一份 5.2 MB 的備份、
    #    照樣印「複驗 ✅」。這種檔連備份都不該做,所以在這裡就停(2026-09-05 加)。
    if len(out) >= total:
        print('  重排後 %.2f MB —— 不會變小。' % (len(out) / 1048576.0))
        print('  那些沒人指到的位元組排在第一筆資料之前,跟檔頭與目錄一起被原樣留著,')
        print('  這一課清不掉它們。這個檔不用清,什麼都沒有動到。')
        return

    print('  重排後 %.2f MB(省 %.2f MB,%.1f%%)'
          % (len(out) / 1048576.0, (total - len(out)) / 1048576.0,
             100.0 * (1 - len(out) / float(total))))
    print('  項目數不變 %d,每一項的位元組原封不動,只有位置變了。' % len(items))

    if not apply_it:
        print()
        print('  以上是預覽,還沒有動到任何檔案。')
        print('  確定要清的話,在剛才那一行最後面加上 --apply')
        return

    # 動手之前先擋掉符號連結(2026-09-05 改)。
    # 這一段原本檢查的是「<原檔名>.tmp 是不是已經被佔用」—— 那是因為暫存檔叫死名字。
    # 現在暫存檔改用 tempfile.mkstemp 產生隨機名字(O_CREAT|O_EXCL),
    # 佔不住也就不必檢查了;真正擋不住的是**正本或備份本身是一個符號連結**:
    #   · 正本是連結 → os.replace 換掉的是連結,真正的球場檔一個位元組都沒清
    #     (main() 在 --apply 這條路上已經先擋掉了,這裡是第二道 ——
    #      2026-09-06 訂正:那裡原本是「先解開再照樣寫」,現在是「直接拒絕」)
    #   · 備份的名字是連結 → 備份會寫到資料夾外面去,而且 os.path.exists()
    #     對「指向不存在的檔」的連結回 False,會被誤判成「還沒有備份」
    backup = path + BACKUP_SUFFIX
    _refuse_if_symlink(path, '要清的封裝檔')
    _refuse_if_symlink(backup, '備份')
    # 旁邊已經有備份時,先驗它能不能信(2026-10-04;理由見 _existing_backup_problem)。
    # 位置在做任何事之前:壞備份被當成「最早那一份」留著,以後就回不去原樣了。
    if os.path.exists(backup):
        _refuse_bad_existing_backup(backup)

    # 備份。**已經有了就不覆蓋** —— 第二次執行時原檔已經是改過的,
    # 蓋上去等於把「動手前的樣子」永遠弄丟。最早那一份才是有價值的那一份。
    if not os.path.exists(backup):
        _atomic_copy(path, backup)
        print()
        print('  已備份 → %s' % os.path.basename(backup))
    else:
        print()
        print('  備份已存在,保留最早那一份 → %s' % os.path.basename(backup))

    # 先寫暫存檔再換名。直接對原檔開 'wb' 的話,寫到一半被中斷就留下一個半截的球場檔;
    # 這樣寫則是「舊的完好」或「新的完整」二選一,沒有中間狀態。
    # 暫存檔用 tempfile.mkstemp 建在**同一個資料夾**裡(換個資料夾 os.replace 就不是
    # 原子的了),名字隨機 + O_CREAT|O_EXCL —— 沒有人能先用一個符號連結佔住那個名字,
    # 把這一次的寫入導到資料夾外面去(2026-09-05 改;原本叫死名字 <原檔名>.tmp)。
    # ⚠️ 2026-09-05 一起補上 flush + fsync:原本沒有這一步,換名之後新內容有沒有
    #    真的落到磁碟,作業系統沒有給保證(同一支腳本的 append_entry 一直都有做)。
    fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(path)) or '.',
                               prefix='.' + os.path.basename(path) + '.tmp-')
    try:
        with os.fdopen(fd, 'wb') as f:
            f.write(bytes(out))
            f.flush()
            os.fsync(f.fileno())
        # 暫存檔是新建的,權限由 mkstemp 決定(0600)—— 直接換名會把原檔的權限一起換掉。
        # 實測本站測試機的封裝檔是 0700(rwx------),先把原檔的權限抄過去再換名。
        try:
            shutil.copymode(path, tmp)
        except OSError:
            pass                            # 抄不到權限不該讓整件事失敗
        # ── 複驗(第一次):**還沒換名之前**先用本檔自己的解析器重讀暫存檔 ──
        # 2026-09-06 加。原本只有換名之後那一次:重排如果算錯,讀者的球場檔會
        # **先被換成壞的**,再靠自動還原救回來 —— 那時候「一個位元組都沒動」
        # 這句話已經不能說了。現在壞的那一份根本不會換上去。
        probs = _verify_pack(tmp, items, raw)
        if probs:
            for _msg in probs:
                print(_msg)
            raise DataError(
                '重排出來的那一份沒有通過複驗(%d 項)—— 它沒有被換上去,'
                '你的遊戲檔一個位元組都沒有動。請把這件事回報給本站。' % len(probs))
        # ⚠️ 「換名」跟「登記換過了」中間不可以有縫(2026-09-06 改)。原本是
        #    os.replace 一行、下一行才登記,Ctrl-C 剛好落在那兩行之間的話,
        #    收尾看到的登記還是「沒動過」,會對著一個**已經被換掉的球場檔**
        #    說「一個位元組都沒有動到」。現在先登記「正在換」,再把換名與登記
        #    一起包進 _NoInterrupt(這段期間的 Ctrl-C 先記著,離開之後才丟出來)。
        # ⚠️ 2026-09-11 再修:登記本身也不可以分成兩行(見上面 _REPLACED 那一段)。
        #    原本 what/path 一行、phase 一行,Ctrl-C 落在中間會讓收尾說
        #    「檔案**已經**換成清過的那一版了」而其實一個位元組都沒換。改成一次 update。
        _REPLACED.update(what='shrink', path=path, phase='replacing')
        with _NoInterrupt():
            os.replace(tmp, path)
            # 換過去了。從這一秒起,被打斷就不可以再說「什麼都沒有動到」。
            _REPLACED['phase'] = 'replaced'
    except BaseException as _e:
        # 沒換成就把暫存檔收掉,原檔完好。
        try:
            os.remove(tmp)
        except OSError:
            pass
        # 只有在「確定沒換成」的時候才可以把「正在換」收回「還沒動」。
        # os.replace 自己丟例外就是那種情形:它要嘛整個做完、要嘛完全沒做。
        # 撤銷一樣是一次寫完(理由同 _do_copy 那一處)。
        if _REPLACED['phase'] == 'replacing' and isinstance(_e, OSError):
            _REPLACED.update(what=None, path=None, phase='idle')
        raise

    # ── 複驗(第二次):每一項都要逐位元組相同 ──────────────────────
    # 換名之前已經對暫存檔驗過一次了(見上面那一段),這一次驗的是**換上去之後的正本**。
    # 兩次不是同一件事重跑:第一次擋的是「重排算錯」(那時候還來得及不換),
    # 第二次擋的是「換名之後磁碟上的東西跟我剛寫出去的不一樣」。
    probs = _verify_pack(path, items, raw)
    for _msg in probs:
        print(_msg)
    bad = len(probs)
    if bad:
        # 複驗沒過就是「寫出去的東西跟原本不一樣」。這時候**不要只印一句叫人自己去還原** ——
        # 備份就在旁邊、而且剛才才驗過,直接換回去比較安全(2026-09-05 加)。
        # 還原走的是 _restore_from_backup:五道把關 + 原子換名,失敗的話正本原封不動。
        print()
        print('  複驗沒過 —— 現在立刻把備份換回去。')
        restored, why = False, ''
        try:
            _restore_from_backup(backup, path)
            restored = True
        except (Exception, SystemExit) as e:   # KeyboardInterrupt 故意不接,讓它照常走 130
            why = str(e) or e.__class__.__name__
        if restored:
            print('  ✓ 已自動還原 ← %s(檔案回到動手前的樣子)' % os.path.basename(backup))
            raise DataError('複驗有 %d 項沒過。已經自動從備份還原了,請把這件事回報給本站。' % bad)
        print('  ⚠️ 自動還原沒有成功:%s' % why)
        # 還原指令放進錯誤訊息本身,不另外印(2026-09-24 改):main() 看到訊息裡
        # 已經有 --restore,就不會再補一段「已經換成清過的那一版」—— 同一行指令
        # 印兩次,讀者會以為要跑兩遍。
        raise DataError('複驗有 %d 項沒過,而且自動還原也失敗了。\n'
                        '  檔案現在還是剛才換上去、複驗沒過的那一版。請自己跑一次:\n'
                        '    python3 -I %s "%s" --restore'
                        % (bad, os.path.basename(__file__), path))
    # 兩次複驗都零問題才走到這裡。項目數用 items —— 數量對不上的話
    # _verify_pack 第一件事就是回報「項目數變了」,根本走不到這一行。
    print('  複驗:%d 個項目全部逐位元組相同,檔頭大小欄正確 ✅' % len(items))
    print()
    print('  進遊戲確認那座球場開得起來。有問題就 --restore。')


def cmd_restore(path):
    """--restore 走這裡:把 `.shrinkbak` 蓋回原檔。

    三層把關,一層比一層深:
      ① 備份存不存在
      ② 備份的開頭是不是 BIGF(不是就代表指錯檔了)
      ③ `_restore_from_backup` 裡那五道(擋半截的備份)
    五道都過之後才走 `_do_copy`,而那一步是**原子的**(2026-09-05 改):
    先寫一個隨機名字的暫存檔、比對 sha256 與長度,最後才 os.replace 換名 ——
    複製途中斷電、磁碟滿、外接碟被拔掉,正本都是原封不動的那一份。
    蓋完再把兩個檔整份讀出來比一次,確定真的一樣才說還原成功。

    ⚠️ 第 ③ 組那五道丟的是 SystemExit,會**繞過** main() 的統一出口:
       訊息跑到 stderr、結束碼變成 1,而檔頭寫著結束碼只有 0 / 2 / 130。
       批次腳本照那份說明去判斷「2 = 自己認出來的問題」,就會漏掉最重要的一種 ——
       備份是壞的、拒絕還原。所以這裡把它轉成 DataError(2026-09-05 加),
       讓它跟其他錯誤走同一個出口。擋人的邏輯完全沒動,只換了怎麼回報。
    """
    backup = path + BACKUP_SUFFIX
    if not os.path.exists(backup):
        raise DataError('找不到備份 %s —— 沒有東西可以還原。' % os.path.basename(backup))
    with open(backup, 'rb') as f:
        if f.read(4) != b'BIGF':
            raise DataError('備份檔開頭不是 BIGF,不敢拿它覆蓋。')
    try:
        _restore_from_backup(backup, path)
    except SystemExit as e:
        # _restore_from_backup 是本站多支腳本共用的一塊,不去改它裡面丟什麼;
        # 在這裡接住,轉成這支腳本自己的錯誤型別。
        raise DataError(str(e))
    # 「印了成功」跟「真的還原了」是兩件事,所以讀回來實際比一次。
    same = open(backup, 'rb').read() == open(path, 'rb').read()
    print('  已還原 ← %s' % os.path.basename(backup))
    print('  複驗:內容與備份%s' % ('相同 ✅' if same else '不同 ❌'))
    if not same:
        raise DataError('還原後內容跟備份不一樣,請手動檢查。')


def _replaced_note():
    """「正本現在是哪一版」—— 換過了或正在換才有話要說,回傳要印的那一段(沒有就回空字串)。

    main() 的兩條「停下來了」都用這一支(2026-09-24 抽出來):作業系統不讓讀寫(OSError),
    以及腳本自己認出來的問題(DataError)。以前只有 OSError 那一條會照登記說話 ——
    而換名之後外接碟被拔掉的時候,big_entries 會把那個 OSError 包成
    DataError('讀不到 …'),走的是另一條路:畫面只印一行「停下來了:讀不到 …」,
    一個字都沒提「檔案已經換成清過的那一版」,也沒給 --restore。
    兩條路要說的是同一件事,寫在同一個地方,就不會一邊有、一邊漏。
    """
    p = _REPLACED['path']
    if _REPLACED['phase'] == 'replacing':
        # 換名那一段被包成不可中斷的區塊之後,這一格幾乎不可能亮 ——
        # 它是保險,而保險亮的時候要說不確定,不可以猜一個好聽的。
        return ('  ⚠️ 出事的時候**正在換名**,換好了沒有沒辦法確定。\n'
                '  請拿旁邊那份 .shrinkbak 跟它比對,或直接還原(還原一定回得去):\n'
                '    python3 -I %s "%s" --restore\n'
                % (os.path.basename(__file__), p))
    if _REPLACED['what'] == 'shrink':
        return ('  ⚠️ 不過檔案**已經**換成清過的那一版了(換名那一步已經做完)。\n'
                '  要回到動手前的樣子:\n'
                '    python3 -I %s "%s" --restore\n'
                % (os.path.basename(__file__), p))
    return ''


def main():
    """看參數決定走哪一條路,並且把所有錯誤收在同一個地方。

    回傳值就是行程的結束碼:0 成功、2 是自己認出來的問題,130 是使用者按 Ctrl-C。
    分開的用意是讓批次腳本能判斷發生了什麼事。**2 這一種涵蓋四類**:
    DataError、「備份是壞的所以拒絕還原」(cmd_restore 轉成 DataError)、
    「你指的那個名字是符號連結,而這一次會寫檔」(2026-09-06 加,一樣是 DataError),
    以及作業系統不讓讀寫這個檔(OSError)。
    --selftest 另外算:0 全部通過、1 有一項沒過、2 是在 python -O 底下拒跑。

    DataError 一律印成一句人話而不是丟 traceback —— 這一課的讀者不是工程師,
    看到一整片紅字只會嚇到,而每一個 DataError 的訊息都已經寫清楚該怎麼辦。
    """
    EPILOG = (
        "\n順序(每一步都可逆):\n\n"
        "  1. 先量:這個檔裡有多少是沒人指到的\n"
        "     python3 -I mvp_shrink_big.py \"<遊戲資料夾>/data/stadium/skydnite.big\"\n\n"
        "  2. 預覽會變多小(不會動到檔案)\n"
        "     python3 -I mvp_shrink_big.py \"<...>/skydnite.big\" --shrink\n\n"
        "  3. 確定了才真的清\n"
        "     python3 -I mvp_shrink_big.py \"<...>/skydnite.big\" --shrink --apply\n\n"
        "  4. 出問題就還原\n"
        "     python3 -I mvp_shrink_big.py \"<...>/skydnite.big\" --restore\n\n"
        "  想確認這支腳本本身沒被改壞(不需要遊戲檔,也不碰任何遊戲檔):\n"
        "     python3 -I mvp_shrink_big.py --selftest\n"
    )
    # --selftest 要在 argparse 之前處理:bigfile 是必填的位置參數,
    # 而自我測試根本不需要遊戲檔。
    if '--selftest' in sys.argv[1:]:
        return selftest()
    ap = argparse.ArgumentParser(
        description='把封裝檔裡目錄沒指到的位元組清掉(內容一個位元組都不少)',
        formatter_class=argparse.RawDescriptionHelpFormatter, epilog=EPILOG)
    ap.add_argument('bigfile', help='要處理的 .big')
    ap.add_argument('--shrink', action='store_true', help='清掉孤兒資料(沒加 --apply 就只是預覽)')
    ap.add_argument('--apply', action='store_true',
                    help='真的寫入(單獨用就會清,不必再加 --shrink)')
    ap.add_argument('--restore', action='store_true', help='從備份還原')
    ap.add_argument('--selftest', action='store_true',
                    help='自我測試(不需要遊戲檔,也不碰任何遊戲檔;不要加 -O)')
    args = ap.parse_args()

    # 符號連結:**會寫檔的那兩條路一律拒絕,唯讀那兩條照樣跟著走。**
    #
    # os.replace 換掉的是**連結本身**:對一個指向真正球場檔的連結跑 --apply,
    # 連結會變成一般檔案,真正那個檔一個位元組都沒清,而腳本照樣印「複驗 ✅」
    # 與「進遊戲確認」(實測就是這樣),備份也寫在連結旁邊而不是本體旁邊。
    # 用 Wine 玩、或用模組管理器把球場檔連過去的裝法都會踩到。
    #
    # ⚠️ 2026-09-06 改。2026-09-05 那一版是「先 realpath 再照樣寫下去」——
    #    比起「寫壞連結本身」是好一點(至少動到的是同一個實體檔),但那仍然是
    #    替使用者決定去改資料夾**外面**的東西,而他打的那個路徑上看不出來。
    #    要改哪一個檔應該由他自己講明白,所以現在直接拒絕(結束碼 2)。
    #    唯讀那兩條路不受限:只是量,跟著連結走沒有風險 —— 而且照著解開之後,
    #    畫面上印給他複製的那兩行 --shrink / --apply 才會指到真正的那個檔。
    target = args.bigfile

    # 三條路的順序有意義:--restore 最優先(那是逃生口,任何時候都該通),
    # 其次才是會寫檔的 --shrink / --apply,都沒給就落到唯讀診斷。
    try:
        if os.path.islink(target):
            if args.restore or args.apply:
                _refuse_if_symlink(target, '要處理的封裝檔')     # 一定會丟 DataError
            target = os.path.realpath(target)
            print('  %s 是一個符號連結,實際處理的是:' % args.bigfile)
            print('    %s' % target)
            print()
        if args.restore:
            cmd_restore(target)
        elif args.shrink or args.apply:
            # 2026-08-28 修:原本 --apply 只是 --shrink 的修飾詞,
            # 單獨給 --apply 會安靜地掉到唯讀診斷 —— 使用者會以為清了但其實沒有。
            # 一個「什麼都沒做卻看起來像做了」的指令,比直接報錯更糟。
            cmd_shrink(target, args.apply)
        else:
            cmd_check(target)
    except DataError as e:
        print('\n  停下來了:%s\n' % e)
        # 照實說有沒有換過(2026-09-24 補)。DataError 大多發生在動手之前,
        # 但換名之後的複驗讀不到檔(外接碟被拔掉)也是 DataError —— big_entries
        # 把 OSError 包成「讀不到 …」丟出來。那時候檔案已經是清過的那一版,
        # 讀者需要的是那一行 --restore,不是只有一句「讀不到」。
        # 訊息自己已經帶著 --restore(自動還原也失敗的那一句)就不再印一次:
        # 同一行指令印兩次,讀者會以為要跑兩遍。
        if '--restore' not in str(e):
            note = _replaced_note()
            if note:
                print(note)
        return 2
    except OSError as e:
        # 遊戲裝在 C:\Program Files\ 之類寫不進去的地方、磁碟滿了、外接碟被拔掉、
        # 或那個檔正被遊戲開著 —— 這些走這裡。原本會噴一整片英文 traceback
        # (實測:唯讀資料夾 + --apply,六層 traceback、結束碼 1),
        # 而這一課的讀者不是工程師,正好是上面那句「一律印成一句人話」保證不會發生的事。
        print('\n  停下來了:作業系統不讓我讀寫這個檔 —— %s\n'
              '  常見原因:遊戲資料夾沒有寫入權限、磁碟滿了、外接碟被拔掉,\n'
              '  或那個檔正被遊戲開著。' % e)
        # 照實說有沒有換過(2026-09-05 加)。這種錯誤**多半**發生在還沒開始寫的階段,
        # 但「多半」不是「一定」—— 換名之後的複驗也會讀檔,那時候外接碟被拔一樣走這裡。
        # 「正在換」與「已經換成清過的那一版」那兩段跟 DataError 共用 _replaced_note()。
        note = _replaced_note()
        if note:
            print(note)
        elif _REPLACED['what'] == 'restore':
            print('  ⚠️ 不過還原那一步**已經**做完了,檔案現在就是備份的那一份。\n')
        else:
            print('  發生在這個階段的話,你的遊戲檔一個位元組都沒動。\n'
                  '  旁邊如果已經有 .shrinkbak,加 --restore 可以確定回到動手前的樣子。\n')
        return 2
    except KeyboardInterrupt:
        # 照實說。「什麼都沒有動到」這句話只有在**還沒 os.replace** 的時候才是真的
        # (2026-09-05 改;原本不管有沒有寫過都印同一句)。
        what, p = _REPLACED['what'], _REPLACED['path']
        if _REPLACED['phase'] == 'replacing':
            # 「換名 + 登記」那一段的保險(_NoInterrupt)裝不上時才會走到這裡
            # (signal.signal 只有主執行緒裝得上)。不確定就要說不確定 ——
            # 這一格的存在就是為了不要在這種時候說「一個位元組都沒有動到」。
            print('\n  已中斷 —— 中斷的時候**正在替換** %s,\n'
                  '  換好了沒有,這邊沒辦法確定。\n'
                  '  請拿旁邊那份 .shrinkbak 跟它比對,或直接還原(還原一定回得去):\n'
                  '    python3 -I %s "%s" --restore\n'
                  % (p, os.path.basename(__file__), p))
        elif what == 'shrink':
            print('\n  已中斷 —— 但檔案**已經**換成清過的那一版了(換名那一步已經做完)。\n'
                  '  要回到動手前的樣子:\n'
                  '    python3 -I %s "%s" --restore\n'
                  % (os.path.basename(__file__), p))
        elif what == 'restore':
            print('\n  已中斷 —— 但還原那一步**已經**做完了,檔案現在就是備份的那一份。\n')
        else:
            print('\n  已中斷。你的遊戲檔一個位元組都沒有動到\n'
                  '  (旁邊可能留下一個看不太懂的隱藏暫存檔,刪掉就好)。\n')
        return 130
    return 0


# ─────────────────────────────────────────────────────────
#  自我測試(--selftest,2026-09-24 加):不需要遊戲檔,全部在系統暫存資料夾裡做
#
#  這一支以前沒有自我測試,守門有沒有在工作只能靠講。現在替守門配了「餌」
#  (不是每一道都有,見 selftest() 的說明):先擺一個會咬人的東西,再看腳本有沒有真的擋下來。而且先做陰性對照 ——
#  證明沒有餌的時候那件事真的做得成,免得「整支腳本罷工」被誤讀成「保護有效」。
#  用的封裝檔全是當場造的迷你假檔,跑完整個暫存資料夾刪掉。
# ─────────────────────────────────────────────────────────

# python -O 跑的時候回傳大於 0 的數字。抽成一支函式,是為了讓 --selftest 換得掉它、
# 給「-O 拒跑」那道守門下一個餌(最後一項):sys.flags 是唯讀的,
# 同一個行程裡切不了 -O,不抽出來就證明不了那道守門真的會擋。
def _optimize_level():
    return sys.flags.optimize


# 最後一項會在 --selftest 裡面再叫一次 selftest()。這一格記著「現在是不是那一次」:
# 守門被拆掉的時候,裡面那一次會一路跑下去,沒有它就會一層套一層停不下來。
_SELFTEST_NESTED = []


def _st_big(path, parts, order='<'):
    """造一個迷你封裝檔寫到 path,只給 --selftest 用。回傳整份位元組。

    parts 依序排在目錄後面:('entry', 名字, 內容) 是目錄會指到的一項,
    ('junk', 位元組) 是沒有人指到的一段(也就是孤兒)。
    目錄照 entry 出現的順序寫,每一項的位移與長度一律大端;
    檔頭 +4 的總長度照 order 寫('<' 小端、'>' 大端,兩種本檔都認)。
    """
    names = [p[1] for p in parts if p[0] == 'entry']
    first = 16 + sum(8 + len(n) + 1 for n in names)
    pos, body, toc = first, b'', b''
    for p in parts:
        if p[0] == 'junk':
            body += p[1]
            pos += len(p[1])
        else:
            toc += struct.pack('>II', pos, len(p[2])) + p[1].encode('latin-1') + b'\x00'
            body += p[2]
            pos += len(p[2])
    raw = (b'BIGF' + struct.pack(order + 'I', first + len(body))
           + struct.pack('>I', len(names)) + struct.pack('>I', first) + toc + body)
    with open(path, 'wb') as f:
        f.write(raw)
    return raw


def selftest():
    """替這支腳本的守門下餌踩一次。全綠回 0,任何一項紅就回 1,在 -O 底下回 2 並拒跑。

    ⚠️ 不是每一道守門都有餌。2026-09-24 把這一段以外、條件成立就讓這支停下來的
       28 道守門逐一拆掉(那一個 if 改成永遠不成立)再跑一次,會變紅的是 13 道。
       拆掉照樣全綠的 15 道裡,12 道在這一課的正式流程根本不會呼叫的共用函式裡
       (qfs_decompress、read_entry、fsh_first_image、fsh_replace_pixels);
       另外 3 道是:共用還原函式裡的「找不到備份」(cmd_restore 自己先擋了這件事)、
       封裝檔小於 8 個位元組、目錄是空的。

    第一件事是擋掉 python -O:-O 會把 assert 整個拿掉,原本會紅的項目有機會
    靜靜地變綠 —— 一份假的綠燈比沒有測試更糟。(這一版每一項用的是 check()
    不是 assert,所以今天 -O 不會讓它變綠;這道守門擋的是「以後有人在這裡加一行
    assert」那一天。)這道守門自己也有一個餌,在最後一項。

    有兩類項目這台機器可能做不到,做不到就跳過,而且畫面上會講出來:
    要建符號連結的(Windows 沒開開發人員模式就建不了),
    以及要裝 Ctrl-C 訊號處理器的(要在主執行緒)。
    """
    if _optimize_level():
        print('  --selftest 不能在 python -O 下跑:-O 會把 assert 全部拿掉,測試會假綠。')
        print('  請拿掉 -O 再跑一次:')
        print('    python3 -I %s --selftest' % os.path.basename(sys.argv[0]))
        return 2
    import io as _io
    import contextlib as _contextlib
    fails, skipped = [], []

    def check(name, ok, detail=''):
        print('  %s %s%s' % ('✅' if ok else '❌', name, ('  —— ' + detail) if detail else ''))
        if not ok:
            fails.append(name)

    def skip(name, why):
        print('  ⏭ %s  —— 跳過:%s' % (name, why))
        skipped.append(name)

    def can_symlink(link, target):
        try:
            os.symlink(target, link)
            return True
        except (OSError, NotImplementedError, AttributeError):
            return False

    def run_main(argv):
        """跑一次**真正的** main(),收起畫面上的字。回傳 (結束碼或丟出來的例外, 畫面上的字)。"""
        argv_save = sys.argv[:]
        buf = _io.StringIO()
        sys.argv = ['mvp_shrink_big.py'] + list(argv)
        try:
            with _contextlib.redirect_stdout(buf):
                try:
                    rc = main()
                except (Exception, SystemExit) as e:
                    rc = e
        finally:
            sys.argv = argv_save
        return rc, buf.getvalue()

    def snap(p):
        """這個檔「是不是同一個檔、內容一不一樣」。換名過的話 inode 會變,光比內容看不出來。"""
        with open(p, 'rb') as f:
            return f.read(), os.stat(p).st_ino

    def reset():
        _REPLACED.update(what=None, path=None, phase='idle')

    def grab(path, it):
        """照目錄那一項的位移與長度把內容讀出來(不借用 read_entry:這一課的正式流程沒有用它)。"""
        with open(path, 'rb') as f:
            f.seek(it[2])
            return f.read(it[3])

    # 一個「清得掉」的球場檔:兩項各 400 個位元組,中間夾 3,000、尾巴 2,000 個沒人指的位元組。
    fat = [('entry', 'a.fsh', b'A' * 400), ('junk', b'\xEE' * 3000),
           ('entry', 'b.fsh', b'B' * 400), ('junk', b'\xEE' * 2000)]
    print('  自我測試(不需要遊戲檔,全部在暫存資料夾裡做)')
    print()
    tmp_root = tempfile.mkdtemp(prefix='mvp_shrink_big_selftest_')
    try:
        d = tmp_root

        # ── 1. 陰性對照:造出來的假檔讀得懂 ──
        big = os.path.join(d, 'stadium.big')
        raw = _st_big(big, fat)
        try:
            ok1 = (size_field_order(big) == '<' and [it[0] for it in big_entries(big)] == ['a.fsh', 'b.fsh'])
        except DataError:
            ok1 = False
        check('陰性對照:造出來的假封裝檔讀得出 2 項,檔頭大小欄位也認得出來', ok1)

        # ── 2. 陰性對照:整條 --apply 真的清得掉、兩輪複驗都過、--restore 回得去 ──
        # 少了這一項,下面每一個「沒動到」都有可能只是因為這條路本來就不通。
        reset()
        rc, out = run_main([big, '--apply'])
        left = sorted(n for n in os.listdir(d) if n.startswith('.'))
        after = open(big, 'rb').read()
        check('陰性對照:沒有餌的時候 --apply 真的把檔變小、做了備份、複驗通過、不留暫存檔',
              rc == 0 and len(after) < len(raw) and os.path.exists(big + BACKUP_SUFFIX)
              and '複驗:2 個項目全部逐位元組相同' in out and not left,
              'rc=%r %d → %d bytes 留下=%r' % (rc, len(raw), len(after), left))
        items2 = {it[0]: it for it in big_entries(big)}
        check('  清完之後兩項的內容逐位元組跟原本一樣',
              grab(big, items2['a.fsh']) == b'A' * 400 and grab(big, items2['b.fsh']) == b'B' * 400)
        reset()
        rc, out = run_main([big, '--restore'])
        check('陰性對照:--restore 逐位元組回到清之前', rc == 0 and open(big, 'rb').read() == raw,
              'rc=%r' % (rc,))
        os.remove(big + BACKUP_SUFFIX)
        reset()

        # ── 3. 餌:換名之前的複驗沒過 → 不可以換上去 ──
        # 讓「驗暫存檔」那一次說有問題。把複驗搬到換名後面的話,正本會先被換掉
        # (inode 會變)—— 這一項就會紅。
        real_vp = globals()['_verify_pack']

        def vp_tmp_bad(check_path, items, raw_):
            if os.path.basename(check_path).startswith('.'):
                return ['  ❌ 假的問題(這是 --selftest 故意製造的)']
            return real_vp(check_path, items, raw_)

        before = snap(big)
        globals()['_verify_pack'] = vp_tmp_bad
        try:
            rc, out = run_main([big, '--apply'])
        finally:
            globals()['_verify_pack'] = real_vp
        left = sorted(n for n in os.listdir(d) if n.startswith('.'))
        check('餌:換名之前的複驗沒過 → 沒有換上去,正本還是同一個檔、同樣的位元組,暫存檔清掉',
              rc == 2 and '沒有被換上去' in out and snap(big) == before and not left
              and _REPLACED['phase'] == 'idle',
              'rc=%r 留下=%r' % (rc, left))
        reset()

        # ── 4. 餌:換名之後的複驗沒過 → 當場自動從備份還原 ──
        calls = []

        def vp_second_bad(check_path, items, raw_):
            calls.append(os.path.basename(check_path))
            if len(calls) >= 2:
                return ['  ❌ 假的問題(這是 --selftest 故意製造的)']
            return real_vp(check_path, items, raw_)

        globals()['_verify_pack'] = vp_second_bad
        try:
            rc, out = run_main([big, '--apply'])
        finally:
            globals()['_verify_pack'] = real_vp
        check('餌:換名之後的複驗沒過 → 當場自動從備份還原,正本逐位元組回到清之前',
              len(calls) == 2 and calls[1] == 'stadium.big' and rc == 2
              and '已自動還原' in out and open(big, 'rb').read() == raw,
              'rc=%r 驗了=%r' % (rc, calls))
        reset()

        # ── 5. 餌:換名那一步失敗(假裝磁碟滿了)→ 沒換成,登記要收回來 ──
        # ⚠️ 只對「換到正本身上」那一次動手腳:備份自己也走 os.replace。
        real_replace = os.replace

        def replace_boom(a, b):
            if os.path.basename(os.fspath(b)) == 'stadium.big':
                raise OSError(28, '假裝磁碟滿了(這是 --selftest 故意製造的)')
            return real_replace(a, b)

        before = snap(big)
        os.replace = replace_boom
        try:
            rc, out = run_main([big, '--apply'])
        finally:
            os.replace = real_replace
        left = sorted(n for n in os.listdir(d) if n.startswith('.'))
        check('餌:換名那一步失敗 → 正本原封不動、暫存檔清掉、畫面說遊戲檔一個位元組都沒動',
              rc == 2 and snap(big) == before and not left and _REPLACED['phase'] == 'idle'
              and '一個位元組都沒動' in out,
              'rc=%r 留下=%r 登記=%r' % (rc, left, _REPLACED['phase']))
        reset()

        # ── 6. 不可中斷段:段內跑完、離開後照丟、處理器換回來 ──
        # ⚠️ 刻意不用 os.kill 送真的訊號:Windows 的 os.kill 收到 SIGINT 是直接砍掉行程。
        #    改成直接叫那個裝上去的處理器,效果一樣,到哪一台都能跑。
        # 先問「這台機器裝不裝得上訊號處理器」,跟 _NoInterrupt 本身分開問:
        # 不分開的話,_NoInterrupt 壞掉(沒裝上去)會被誤報成「這台做不到」而跳過。
        try:
            _prev = signal.getsignal(signal.SIGINT)
            signal.signal(signal.SIGINT, _prev if _prev is not None else signal.default_int_handler)
            sig_ok = True
        except (ValueError, OSError, TypeError, AttributeError):
            sig_ok = False
        if sig_ok:
            before_h = signal.getsignal(signal.SIGINT)
            wired = ran_to_end = raised_after = False
            try:
                with _NoInterrupt() as ni:
                    wired = signal.getsignal(signal.SIGINT) == ni._remember
                    if wired:
                        signal.getsignal(signal.SIGINT)(signal.SIGINT, None)
                    ran_to_end = True
            except KeyboardInterrupt:
                raised_after = True
            restored = signal.getsignal(signal.SIGINT) == before_h
            check('不可中斷段:Ctrl-C 先記著、段內那幾行照樣跑完、離開後照丟、處理器換回來',
                  wired and ran_to_end and raised_after and restored,
                  '裝上了=%s 段內跑完=%s 離開後照丟=%s 處理器換回來=%s'
                  % (wired, ran_to_end, raised_after, restored))
        else:
            skip('不可中斷段', '這台裝不上 Ctrl-C 的訊號處理器(要在主執行緒才裝得上)')

        # ── 7. 餌:Ctrl-C 落在「換名剛做完、登記還沒做完」那一瞬間 ──
        # 收尾不可以說「一個位元組都沒動到」,要照實說已經換成清過的那一版、印還原指令。
        if sig_ok:
            def replace_then_ctrl_c(a, b):
                r = real_replace(a, b)
                if os.path.basename(os.fspath(b)) == 'stadium.big':
                    signal.getsignal(signal.SIGINT)(signal.SIGINT, None)   # 等於在這裡按了 Ctrl-C
                return r

            os.replace = replace_then_ctrl_c
            try:
                rc, out = run_main([big, '--apply'])
            finally:
                os.replace = real_replace
            check('餌:Ctrl-C 落在換名剛做完那一瞬間 → 結束碼 130、照實說已經換過了、印出 --restore',
                  rc == 130 and '換成清過的那一版' in out and '--restore' in out
                  and _REPLACED['phase'] == 'replaced' and os.path.getsize(big) < len(raw),
                  'rc=%r 登記=%r' % (rc, _REPLACED['phase']))
            reset()
            run_main([big, '--restore'])
            reset()
            if open(big, 'rb').read() != raw:
                check('  (餌 7 之後沒能還原回起點,下面的項目不可信)', False)
        else:
            skip('餌:Ctrl-C 落在換名剛做完那一瞬間', '這台裝不上 Ctrl-C 的訊號處理器')
        if os.path.exists(big + BACKUP_SUFFIX):
            os.remove(big + BACKUP_SUFFIX)

        # ── 8. 餌:孤兒排在第一筆資料之前(重排不會變小)→ 不寫、不做備份 ──
        front = os.path.join(d, 'front.big')
        _st_big(front, [('entry', 'a.fsh', b'A' * 400), ('entry', 'b.fsh', b'B' * 400)])
        # 把目錄那兩項的位移往後推 5,000,前面塞沒人指的位元組 —— 就是 igonly.big 那種長相
        fr = bytearray(open(front, 'rb').read())
        first = struct.unpack('>I', bytes(fr[16:20]))[0]
        fr = fr[:first] + bytearray(b'\xEE' * 5000) + fr[first:]
        struct.pack_into('>I', fr, 16, first + 5000)
        struct.pack_into('>I', fr, 16 + 8 + 6, first + 5000 + 400)
        struct.pack_into('<I', fr, 4, len(fr))
        with open(front, 'wb') as f:
            f.write(bytes(fr))
        before = snap(front)
        rc, out = run_main([front, '--apply'])
        check('餌:孤兒全排在第一筆資料之前 → 說「不會變小」,不寫、連備份都不做',
              rc == 0 and '不會變小' in out and snap(front) == before
              and not os.path.exists(front + BACKUP_SUFFIX), 'rc=%r' % (rc,))

        # ── 9. 餌:孤兒不到 1 KB → 不寫、不做備份 ──
        tiny = os.path.join(d, 'tiny.big')
        _st_big(tiny, [('entry', 'a.fsh', b'A' * 400), ('junk', b'\xEE' * 100),
                       ('entry', 'b.fsh', b'B' * 400)])
        before = snap(tiny)
        rc, out = run_main([tiny, '--apply'])
        check('餌:沒人指到的不到 1 KB → 說「不用清」,不寫、不做備份',
              rc == 0 and '不用清' in out and snap(tiny) == before
              and not os.path.exists(tiny + BACKUP_SUFFIX), 'rc=%r' % (rc,))

        # ── 10. 反向:名字重複的封裝檔 → 寫得完全正確的檔不可以被誤判成失敗 ──
        dup = os.path.join(d, 'dup.big')
        _st_big(dup, [('entry', 'dup.fsh', b'A' * 400), ('junk', b'\xEE' * 2000),
                      ('entry', 'other.fsh', b'B' * 400), ('junk', b'\xEE' * 2000),
                      ('entry', 'dup.fsh', b'C' * 400)])
        rc, out = run_main([dup, '--apply'])
        got = [grab(dup, it)[:1] for it in big_entries(dup)]
        check('反向:名字重複的項目照位置配對 → 清得乾淨、複驗通過,不會被誤判成失敗',
              rc == 0 and got == [b'A', b'B', b'C'] and '已自動還原' not in out,
              'rc=%r 內容=%r' % (rc, got))

        # ── 11. 餌:目錄指到檔案外面 → 不敢動 ──
        oob = os.path.join(d, 'oob.big')
        ob = bytearray(_st_big(oob, fat))
        struct.pack_into('>I', ob, 20, 999999)          # 第一項的長度改成比檔案還大
        with open(oob, 'wb') as f:
            f.write(bytes(ob))
        before = snap(oob)
        rc, out = run_main([oob, '--apply'])
        check('餌:目錄有一項指到檔案外面 → 停下來(結束碼 2),不寫、不做備份',
              rc == 2 and '超出檔案大小' in out and snap(oob) == before
              and not os.path.exists(oob + BACKUP_SUFFIX), 'rc=%r' % (rc,))

        # ── 12. 餌:目錄宣告的筆數比解析得出來的多 → 不敢動 ──
        short = os.path.join(d, 'short.big')
        sb = (b'BIGF' + b'\x00' * 4 + struct.pack('>I', 2) + b'\x00' * 4
              + struct.pack('>II', 40, 4) + b'a.fsh\x00'
              + struct.pack('>II', 44, 4) + b'bbbb' + b'XY' * 20)   # 第二個名字沒有結尾的 0
        sb = sb[:4] + struct.pack('<I', len(sb)) + sb[8:]
        with open(short, 'wb') as f:
            f.write(sb)
        try:
            big_entries(short)
            blocked = False
        except DataError as e:
            blocked = '只解析得出 1 筆' in str(e)
        check('餌:目錄宣告 2 筆只解析得出 1 筆 → 停下來,不回傳半份目錄', blocked)

        # ── 13. 餌:開頭不是 BIGF、項目數是 0 → 不當成封裝檔 ──
        # 「開頭不是 BIGF」那一個,後面照抄一份完整合法的封裝檔 ——
        # 只改招牌,擋住它的才只會是招牌那一道,不會是別的檢查順便擋掉。
        notbig = os.path.join(d, 'notbig.big')
        with open(notbig, 'wb') as f:
            f.write(b'NOPE' + raw[4:])
        zero = os.path.join(d, 'zero_items.big')
        with open(zero, 'wb') as f:
            f.write(b'BIGF' + struct.pack('<I', 16) + struct.pack('>I', 0) + b'\x00' * 4)
        bad = 0
        for p in (notbig, zero):
            try:
                big_entries(p)
            except DataError:
                bad += 1
        check('餌:開頭不是 BIGF / 目錄項目數是 0 → 兩種都停下來', bad == 2, '擋下 %d / 2' % bad)

        # ── 14. 餌:檔頭大小欄位跟實際檔案大小對不上 → 停下來 ──
        # 陰性對照一起做:大端的檔頭要認得出來(本站測試機真的有這種檔)。
        mis = os.path.join(d, 'mismatch.big')
        mb = bytearray(_st_big(mis, fat))
        struct.pack_into('<I', mb, 4, 123)
        with open(mis, 'wb') as f:
            f.write(bytes(mb))
        be = os.path.join(d, 'bigendian.big')
        _st_big(be, fat, order='>')
        try:
            size_field_order(mis)
            blocked = False
        except DataError:
            blocked = True
        check('餌:檔頭大小欄位對不上 → 停下來;陰性對照:大端的檔頭認得出來',
              blocked and size_field_order(be) == '>')

        # ── 15. 餌:複驗自己要抓得到「內容被換掉」與「檔頭大小不對」 ──
        # 陰性對照:一模一樣的檔 → 零個問題(不然上面那些「複驗通過」都不算數)。
        vp_raw = open(big, 'rb').read()
        vp_items = big_entries(big)
        same = real_vp(big, vp_items, vp_raw)
        vp1 = os.path.join(d, 'vp_content.big')
        with open(vp1, 'wb') as f:
            f.write(vp_raw)
        it_a = vp_items[0]
        with open(vp1, 'r+b') as f:
            f.seek(it_a[2])
            f.write(b'Z')
        vp2 = os.path.join(d, 'vp_header.big')
        with open(vp2, 'wb') as f:
            f.write(vp_raw + b'\x00\x00\x00\x00')       # 多 4 個位元組,檔頭沒跟著改
        p1 = real_vp(vp1, vp_items, vp_raw)
        p2 = real_vp(vp2, vp_items, vp_raw)
        check('餌:複驗抓得到「某一項內容不一樣」與「檔頭大小欄對不上」;一模一樣的檔零問題',
              same == [] and any('內容跟原本不同' in x for x in p1)
              and any('檔頭的大小欄' in x for x in p2),
              '一樣=%d 內容=%d 檔頭=%d' % (len(same), len(p1), len(p2)))

        # ── 16. 餌:備份要放的位置是符號連結 → 拒絕,外面那個檔不可以被動到 ──
        outside = os.path.join(d, 'outside')
        os.makedirs(outside)
        victim = os.path.join(outside, '別人的檔.txt')
        with open(victim, 'wb') as f:
            f.write(b'DO-NOT-TOUCH' * 50)
        keep = open(victim, 'rb').read()
        if can_symlink(big + BACKUP_SUFFIX, victim):
            before = snap(big)
            rc, out = run_main([big, '--apply'])
            check('餌:備份的位置是符號連結 → 停下來(結束碼 2),正本與連結指到的檔都沒被動到',
                  rc == 2 and '備份 是一個符號連結' in out and snap(big) == before
                  and open(victim, 'rb').read() == keep, 'rc=%r' % (rc,))
            # 第二層:備份那一支自己也要擋(上面那一層被拿掉時,靠的就是這一層)
            try:
                _atomic_copy(big, big + BACKUP_SUFFIX)
                blocked = False
            except DataError:
                blocked = True
            check('  備份那一支自己也擋:直接叫它寫到連結上 → 拒絕,外面那個檔沒被動到',
                  blocked and open(victim, 'rb').read() == keep)
            os.remove(big + BACKUP_SUFFIX)
        else:
            skip('餌:備份的位置是符號連結', '這台做不出符號連結(Windows 要開發人員模式或系統管理員)')

        # ── 17. 餌:猜得到的暫存名(<備份>.part)先被做成連結 → 備份照樣做、外面不動 ──
        part = big + BACKUP_SUFFIX + '.part'
        if can_symlink(part, victim):
            _atomic_copy(big, big + BACKUP_SUFFIX)
            check('餌:<備份>.part 先被做成指到外面的連結 → 備份照樣做成,外面那個檔沒被動到',
                  open(victim, 'rb').read() == keep
                  and open(big + BACKUP_SUFFIX, 'rb').read() == open(big, 'rb').read())
            for _p in (part, big + BACKUP_SUFFIX):
                if os.path.lexists(_p):
                    os.remove(_p)
        else:
            skip('餌:<備份>.part 先被做成連結', '這台做不出符號連結')

        # ── 18. 餌:你指的那個 .big 本身是符號連結 → 會寫檔的兩條路拒絕,只量的照樣走 ──
        real_big = os.path.join(outside, '真正的球場.big')
        _st_big(real_big, fat)
        link_big = os.path.join(d, 'link.big')
        if can_symlink(link_big, real_big):
            before = snap(real_big)
            rc_a, out_a = run_main([link_big, '--apply'])
            rc_r, out_r = run_main([link_big, '--restore'])
            rc_c, out_c = run_main([link_big])
            check('餌:.big 是符號連結 → --apply 與 --restore 都拒絕(結束碼 2),連結指到的檔沒被動到',
                  rc_a == 2 and rc_r == 2 and snap(real_big) == before
                  and not os.path.exists(real_big + BACKUP_SUFFIX)
                  and not os.path.exists(link_big + BACKUP_SUFFIX),
                  'apply=%r restore=%r' % (rc_a, rc_r))
            check('  陰性對照:只量(不加參數)照樣跟著連結走到本體去量',
                  rc_c == 0 and '實際處理的是' in out_c, 'rc=%r' % (rc_c,))
            # 第二層:清檔那一支自己也要擋(main 那一層被拿掉時,靠的就是這一層)
            try:
                with _contextlib.redirect_stdout(_io.StringIO()):
                    cmd_shrink(link_big, True)
                blocked = False
            except DataError:
                blocked = True
            check('  清檔那一支自己也擋:直接叫它清一個連結 → 拒絕,連結指到的檔沒被動到',
                  blocked and snap(real_big) == before
                  and not os.path.exists(real_big + BACKUP_SUFFIX))
        else:
            skip('餌:.big 是符號連結', '這台做不出符號連結')

        # ── 19. 餌:備份寫到一半失敗 → 不留暫存檔,備份的名字上也不會出現半截的檔 ──
        def replace_boom_any(a, b):
            raise OSError(28, '假裝磁碟滿了(這是 --selftest 故意製造的)')

        os.replace = replace_boom_any
        try:
            try:
                _atomic_copy(big, big + BACKUP_SUFFIX)
                blew = False
            except OSError:
                blew = True
        finally:
            os.replace = real_replace
        left = sorted(n for n in os.listdir(d) if n.startswith('.'))
        check('餌:備份最後一步失敗 → 往上報、不留暫存檔、備份的名字上沒有半截的檔',
              blew and not left and not os.path.lexists(big + BACKUP_SUFFIX), '留下=%r' % (left,))

        # ── 25 到 27. 餌:旁邊已經有備份時,--apply 要先驗它(2026-10-04 補;上線前稽核找到的舊問題)──
        # 以前「備份已存在,保留最早那一份」完全不看那份備份的內容。拿掉 _refuse_bad_existing_backup 的話,25 與 26 會紅
        # (球場檔被清了、壞備份被留成「最早那一份」);27 是陰性對照:好備份要照常往下做。
        def apply_with_backup(name, bak_bytes):
            bigx = os.path.join(d, name)
            rawx = _st_big(bigx, fat)
            with open(bigx + BACKUP_SUFFIX, 'wb') as f:
                f.write(bak_bytes(rawx))
            beforex = snap(bigx)
            reset()
            rcx, outx = run_main([bigx, '--apply'])
            leftx = sorted(n for n in os.listdir(d) if n.startswith('.' + name))
            return bigx, rawx, beforex, rcx, outx, leftx

        bigx, rawx, beforex, rcx, outx, leftx = apply_with_backup('stad_bak_trunc.big', lambda r: r[:len(r) // 8])
        check('餌:旁邊的備份被截斷過 → --apply 在動任何檔之前停下來(結束碼 2),球場檔與壞備份都原封不動',
              rcx == 2 and '是壞的' in outx and '被截斷' in outx and '球場檔與備份都沒有動' in outx
              and snap(bigx) == beforex and open(bigx + BACKUP_SUFFIX, 'rb').read() == rawx[:len(rawx) // 8] and not leftx,
              'rc=%r 留下=%r' % (rcx, leftx))
        bigx, rawx, beforex, rcx, outx, leftx = apply_with_backup('stad_bak_zero.big', lambda r: b'')
        check('餌:旁邊的備份是 0 位元組 → 同樣停下來,球場檔不動、備份還是 0 位元組',
              rcx == 2 and '是壞的' in outx and '0 位元組' in outx and snap(bigx) == beforex
              and os.path.getsize(bigx + BACKUP_SUFFIX) == 0 and not leftx, 'rc=%r' % (rcx,))
        bigx, rawx, beforex, rcx, outx, leftx = apply_with_backup('stad_bak_nobigf.big', lambda r: b'NOPE' + r[4:])
        check('餌:旁邊的備份大小與檔頭長度都對、只有開頭不是 BIGF → 同樣停下來(只有「不是 BIGF」那一道擋得住它),球場檔與備份都不動',
              rcx == 2 and '是壞的' in outx and '不是 BIGF' in outx and snap(bigx) == beforex
              and open(bigx + BACKUP_SUFFIX, 'rb').read() == b'NOPE' + rawx[4:] and not leftx, 'rc=%r' % (rcx,))
        bigx, rawx, beforex, rcx, outx, leftx = apply_with_backup('stad_bak_ok.big', lambda r: r)
        check('陰性對照:旁邊的備份是好的 → 照常往下做(印「備份已存在」、球場檔變小、備份沒被蓋掉)',
              rcx == 0 and '備份已存在' in outx and len(open(bigx, 'rb').read()) < len(rawx)
              and open(bigx + BACKUP_SUFFIX, 'rb').read() == rawx, 'rc=%r' % (rcx,))
        reset()

        # ── 20 到 24. 餌:壞掉的備份不可以拿來蓋正本(五道各一個)──
        # 每一種都拿「完整的那一份」先做陰性對照:完整的要還原得了,截斷的要被擋下來。
        def restore_blocked(bak_bytes, target_bytes, name):
            t = os.path.join(d, name)
            with open(t, 'wb') as f:
                f.write(target_bytes)
            b = t + '.selftest-backup'
            with open(b, 'wb') as f:
                f.write(bak_bytes)
            try:
                _restore_from_backup(b, t)
                blocked_ = False
            except SystemExit:
                blocked_ = True
            return blocked_, open(t, 'rb').read() == target_bytes

        # 20. 0 bytes —— 兩種情況都要擋:目標還在(後面那道「不到一半」也擋得住),
        #     以及目標已經不見了(那時候只剩這一道擋得住,少了它會生出一個 0 bytes 的遊戲檔)
        blk, untouched = restore_blocked(b'', raw, 'r0.big')
        gone = os.path.join(d, 'r0_gone.big')
        with open(gone + '.selftest-backup', 'wb') as f:
            pass
        try:
            _restore_from_backup(gone + '.selftest-backup', gone)
            blk_gone = False
        except SystemExit:
            blk_gone = True
        check('餌:0 bytes 的備份 → 擋下來,正本沒被動到;目標已經不見的時候也不會生出一個空檔',
              blk and untouched and blk_gone and not os.path.exists(gone))

        # 21. BIGF 被截斷(檔頭還宣稱原本的長度)
        blk, untouched = restore_blocked(raw[:len(raw) // 2], raw, 'r1.big')
        ok_blk, _u = restore_blocked(raw, raw + b'\x00' * 8, 'r1ok.big')
        check('餌:截成一半的封裝檔備份 → 擋下來;陰性對照:完整的那一份還原得了',
              blk and untouched and not ok_blk)

        # 22. 語系檔(LOCH)被截斷
        L = 20
        strs = [b'A\x00B\x00\x00\x00\x00\x00', b'C\x00D\x00\x00\x00\x00\x00', b'E\x00F\x00\x00\x00\x00\x00']
        loch = (b'LOCH' + b'\x00' * 12 + struct.pack('<I', L)
                + b'LOCL' + b'\x00' * 8 + struct.pack('<I', 3)
                + struct.pack('<III', 28, 36, 44) + b''.join(strs))
        blk, untouched = restore_blocked(loch[:60], loch, 'r2.loc')
        ok_blk, _u = restore_blocked(loch, loch[:-1] + b'\x01', 'r2ok.loc')
        check('餌:最後一條字串被截掉的語系檔備份 → 擋下來;陰性對照:完整的那一份還原得了',
              blk and untouched and not ok_blk)

        # 23. 執行檔(MZ)被截斷
        mz = bytearray(0x200)
        mz[0:2] = b'MZ'
        struct.pack_into('<I', mz, 0x3C, 0x40)
        mz[0x40:0x44] = b'PE\x00\x00'
        struct.pack_into('<H', mz, 0x46, 1)             # 一個節區
        struct.pack_into('<H', mz, 0x54, 0)             # 選用檔頭長度 0
        struct.pack_into('<II', mz, 0x58 + 16, 0x100, 0x100)   # 節區在 0x100,長 0x100
        mz = bytes(mz)
        blk, untouched = restore_blocked(mz[:0x180], mz, 'r3.exe')
        ok_blk, _u = restore_blocked(mz, mz[:-1] + b'\x01', 'r3ok.exe')
        check('餌:節區被截掉的執行檔備份 → 擋下來;陰性對照:完整的那一份還原得了',
              blk and untouched and not ok_blk)

        # 24. 認不出格式的備份,不到正本的一半
        blk, untouched = restore_blocked(b'x' * 100, b'y' * 1000, 'r4.dat')
        ok_blk, _u = restore_blocked(b'x' * 600, b'y' * 1000, 'r4ok.dat')
        check('餌:認不出格式、而且不到正本一半的備份 → 擋下來;陰性對照:夠大的還原得了',
              blk and untouched and not ok_blk)

        # ── 25. 餌:還原寫出來的那一份跟備份對不上 → 不換上去 ──
        # 在「寫完、讀回來比對之前」把暫存檔改掉一個位元組(模擬磁碟寫錯了)。
        t = os.path.join(d, 'r5.big')
        with open(t, 'wb') as f:
            f.write(raw + b'\x00' * 8)
        with open(t + '.selftest-backup', 'wb') as f:
            f.write(raw)
        before = snap(t)
        real_cm = shutil.copymode

        def copymode_then_corrupt(src, dst):
            with open(dst, 'r+b') as f:
                f.write(b'Z')

        shutil.copymode = copymode_then_corrupt
        try:
            try:
                _do_copy(t + '.selftest-backup', t)
                blocked = False
            except DataError:
                blocked = True
        finally:
            shutil.copymode = real_cm
        left = sorted(n for n in os.listdir(d) if n.startswith('.r5'))
        check('餌:還原寫出來的那一份跟備份對不上 → 不換上去,正本原封不動,暫存檔清掉',
              blocked and snap(t) == before and not left, '留下=%r' % (left,))

        # ── 26. 餌:還原的最後一步(換名)失敗 → 正本原封不動、登記收回來 ──
        reset()

        def replace_boom_r5(a, b):
            if os.path.basename(os.fspath(b)) == 'r5.big':
                raise OSError(28, '假裝磁碟滿了(這是 --selftest 故意製造的)')
            return real_replace(a, b)

        os.replace = replace_boom_r5
        try:
            try:
                _do_copy(t + '.selftest-backup', t)
                blew = False
            except OSError:
                blew = True
        finally:
            os.replace = real_replace
        left = sorted(n for n in os.listdir(d) if n.startswith('.r5'))
        check('餌:還原換名那一步失敗 → 正本原封不動、暫存檔清掉、登記收回「沒動過」',
              blew and snap(t) == before and not left and _REPLACED['phase'] == 'idle',
              '登記=%r' % (_REPLACED['phase'],))
        reset()

        # ── 27. 餌:要還原的目標是符號連結 → 拒絕 ──
        link_t = os.path.join(d, 'r6.big')
        if can_symlink(link_t, victim):
            try:
                _do_copy(t + '.selftest-backup', link_t)
                blocked = False
            except DataError:
                blocked = True
            check('餌:要還原的目標是符號連結 → 拒絕,連結指到的檔沒被動到',
                  blocked and open(victim, 'rb').read() == keep)
        else:
            skip('餌:要還原的目標是符號連結', '這台做不出符號連結')

        # ── 28. 餌:--restore 拿到壞的備份 → 結束碼 2(不是 1),正本不動 ──
        # 半截備份那一道丟的是 SystemExit,不接住的話結束碼會是 1、訊息跑到別處。
        with open(big + BACKUP_SUFFIX, 'wb') as f:
            f.write(raw[:len(raw) // 2])
        before = snap(big)
        rc_t, out_t = run_main([big, '--restore'])
        with open(big + BACKUP_SUFFIX, 'wb') as f:
            f.write(b'NOPE' * 100)
        rc_n, out_n = run_main([big, '--restore'])
        os.remove(big + BACKUP_SUFFIX)
        rc_m, out_m = run_main([big, '--restore'])
        check('餌:--restore 碰到半截備份 / 不是封裝檔的備份 / 沒有備份 → 三種都結束碼 2,正本不動',
              rc_t == 2 and rc_n == 2 and rc_m == 2 and snap(big) == before
              and '備份是壞的' in out_t and '開頭不是 BIGF' in out_n and '找不到備份' in out_m,
              '結束碼 %r / %r / %r' % (rc_t, rc_n, rc_m))

        # ── 29 到 32:2026-09-24 補上原本沒有餌的幾道(一樣跑真正的 main())──
        # 從同一個起點出發:正本是「清之前」那一份,旁邊沒有備份。
        raw = _st_big(big, fat)
        bak = big + BACKUP_SUFFIX
        if os.path.lexists(bak):
            os.remove(bak)
        reset()

        # ── 29. 預設唯讀:不加參數只量、只加 --shrink 只預覽 → 一個位元組都不寫 ──
        # 把預覽那條路改成會寫檔的話,正本會被換掉(inode 會變)、旁邊會多一份備份 —— 這一項就會紅。
        before = snap(big)
        rc_c, out_c = run_main([big])
        rc_s, out_s = run_main([big, '--shrink'])
        left = sorted(n for n in os.listdir(d) if n.startswith('.'))
        check('預設唯讀:不加參數只量、只加 --shrink 只預覽 → 正本還是同一個檔、同樣的位元組,'
              '不做備份、不留暫存檔',
              rc_c == 0 and rc_s == 0 and '以上是預覽' in out_s and snap(big) == before
              and not os.path.lexists(bak) and not left,
              'rc=%r / %r 備份在不在=%s 留下=%r' % (rc_c, rc_s, os.path.lexists(bak), left))
        reset()

        # ── 30. 餌:旁邊已經有備份 → 保留最早那一份,不可以拿現在的正本蓋掉它 ──
        # 先在備份的位置放一份「更早的」封裝檔(完整合法,但跟現在的正本不一樣)。
        # 改成每次都重做備份的話,那一份會被蓋成現在的正本 —— 這一項就會紅。
        older = _st_big(os.path.join(d, 'older.big'),
                        [('entry', 'a.fsh', b'A' * 400), ('junk', b'\xDD' * 1234),
                         ('entry', 'b.fsh', b'B' * 400)])
        with open(bak, 'wb') as f:
            f.write(older)
        rc, out = run_main([big, '--apply'])
        check('餌:旁邊已經有備份 → 照樣清得掉,但那份最早的備份一個位元組都沒被蓋掉',
              rc == 0 and '保留最早那一份' in out and open(bak, 'rb').read() == older
              and os.path.getsize(big) < len(raw),
              'rc=%r 備份%s' % (rc, '沒變' if open(bak, 'rb').read() == older else '被蓋掉了'))
        with open(big, 'wb') as f:                  # 回到起點(不走 --restore:備份是 older 那一份)
            f.write(raw)
        os.remove(bak)
        reset()

        # ── 31. 餌:換名之後的複驗沒過,而且**自動還原也失敗** → 照實說,並印出 --restore 那一行 ──
        # 這是讀者的球場檔已經被換掉、又救不回來時唯一會看到的一句話。
        # 把「自動還原失敗」講成「已自動還原」、或把那一行 --restore 拿掉,這一項都會紅。
        calls31 = []

        def vp_second_bad31(check_path, items, raw_):
            calls31.append(os.path.basename(check_path))
            if len(calls31) >= 2:
                return ['  ❌ 假的問題(這是 --selftest 故意製造的)']
            return real_vp(check_path, items, raw_)

        real_rfb = globals()['_restore_from_backup']

        def restore_fails(bak_, dst_):
            raise SystemExit('假的還原失敗(這是 --selftest 故意製造的)')

        globals()['_verify_pack'] = vp_second_bad31
        globals()['_restore_from_backup'] = restore_fails
        try:
            rc, out = run_main([big, '--apply'])
        finally:
            globals()['_verify_pack'] = real_vp
            globals()['_restore_from_backup'] = real_rfb
        check('餌:換名之後複驗沒過、自動還原也失敗 → 結束碼 2,說「自動還原沒有成功」、'
              '印出 --restore 那一行,不說「已自動還原」',
              len(calls31) == 2 and rc == 2 and '自動還原沒有成功' in out
              and ('"%s" --restore' % big) in out and '已自動還原' not in out,
              'rc=%r 驗了=%r' % (rc, calls31))
        reset()
        rc_back, _o = run_main([big, '--restore'])  # 照它印的那一行做,回到起點
        check('  陰性對照:照畫面印的那一行 --restore 跑一次 → 逐位元組回到清之前',
              rc_back == 0 and open(big, 'rb').read() == raw, 'rc=%r' % (rc_back,))
        reset()

        # ── 32. 餌:--restore 蓋完之後,內容跟備份差一個位元組 → 不可以說還原成功 ──
        # 讓還原「做完」但寫出來的東西跟備份差最後一個位元組(模擬磁碟寫錯了)。
        # 拿掉 --restore 最後那道逐位元組比對的話,結束碼會是 0 —— 這一項就會紅。
        def restore_off_by_one(bak_, dst_):
            data_ = bytearray(open(bak_, 'rb').read())
            data_[-1] ^= 0xFF
            with open(dst_, 'wb') as f:
                f.write(bytes(data_))

        globals()['_restore_from_backup'] = restore_off_by_one
        try:
            rc, out = run_main([big, '--restore'])
        finally:
            globals()['_restore_from_backup'] = real_rfb
        check('餌:--restore 蓋完之後內容跟備份差一個位元組 → 結束碼 2,說「還原後內容跟備份不一樣」',
              rc == 2 and '還原後內容跟備份不一樣' in out and '相同 ✅' not in out,
              'rc=%r' % (rc,))

        # ── 33 到 39:2026-09-24 再補的(一樣跑真正的 main())──
        # 這一輪補上的:換名之後讀不到檔(DataError 與 OSError 兩條路)、「正在換名」那一格保險、
        # 自動還原的換名也失敗、--restore 時備份是符號連結、換名之前按 Ctrl-C、
        # 還原做完之後才讀不到備份。拆掉對應的那一行,這幾項各自會紅(實測過)。
        # 從同一個起點出發:正本是「清之前」那一份,旁邊沒有備份。
        with open(big, 'wb') as f:
            f.write(raw)
        if os.path.lexists(bak):
            os.remove(bak)
        reset()

        # ── 33. 餌:換名之後的複驗讀不到檔(外接碟被拔掉那一種)──
        # 第二次複驗(讀正本那一次)開檔之前,先把正本搬走 —— 等於外接碟被拔掉。
        # big_entries 會把開不了檔的 OSError 包成 DataError('讀不到 …'),走的是 main()
        # 的 DataError 那一條;那一條以前只印「停下來了:讀不到 …」,一個字都沒提
        # 檔案已經換成清過的那一版,也沒給 --restore。那一條不照登記說話,這一項就會紅。
        away = os.path.join(d, 'pulled_out.big')
        calls33 = []

        def vp_file_gone(check_path, items, raw_):
            calls33.append(os.path.basename(check_path))
            if len(calls33) >= 2:
                os.rename(check_path, away)          # 正本「不見了」
            return real_vp(check_path, items, raw_)

        globals()['_verify_pack'] = vp_file_gone
        try:
            rc, out = run_main([big, '--apply'])
        finally:
            globals()['_verify_pack'] = real_vp
        check('餌:換名之後複驗讀不到檔(外接碟被拔掉)→ 結束碼 2,說已經換成清過的那一版、'
              '印出 --restore 那一行,不說「一個位元組都沒動」',
              len(calls33) == 2 and rc == 2 and '讀不到' in out and '已經' in out
              and ('"%s" --restore' % big) in out and '一個位元組都沒動' not in out,
              'rc=%r 驗了=%r' % (rc, calls33))
        if os.path.exists(away):
            os.rename(away, big)                     # 外接碟插回去
        reset()
        rc_back, _o = run_main([big, '--restore'])
        check('  陰性對照:檔案放回去、照畫面印的那一行 --restore 跑一次 → 逐位元組回到清之前',
              rc_back == 0 and open(big, 'rb').read() == raw, 'rc=%r' % (rc_back,))
        if os.path.lexists(bak):
            os.remove(bak)
        reset()

        # ── 34. 餌:換名之後的複驗丟出作業系統的錯(沒被 big_entries 包起來的那一種)──
        # 走的是 main() 的 OSError 那一條。把那一條的「已經換成清過的那一版」
        # 改成「一個位元組都沒動」,這一項就會紅。
        calls34 = []

        def vp_oserror(check_path, items, raw_):
            calls34.append(os.path.basename(check_path))
            if len(calls34) >= 2:
                raise OSError(5, '假裝讀檔失敗(這是 --selftest 故意製造的)')
            return real_vp(check_path, items, raw_)

        globals()['_verify_pack'] = vp_oserror
        try:
            rc, out = run_main([big, '--apply'])
        finally:
            globals()['_verify_pack'] = real_vp
        check('餌:換名之後複驗丟出作業系統的錯 → 結束碼 2,說已經換成清過的那一版、'
              '印出 --restore 那一行,不說「一個位元組都沒動」',
              len(calls34) == 2 and rc == 2 and '作業系統不讓我讀寫' in out and '已經' in out
              and ('"%s" --restore' % big) in out and '一個位元組都沒動' not in out,
              'rc=%r 驗了=%r' % (rc, calls34))
        reset()
        run_main([big, '--restore'])
        if os.path.lexists(bak):
            os.remove(bak)
        reset()
        if open(big, 'rb').read() != raw:
            check('  (餌 34 之後沒能還原回起點,下面的項目不可信)', False)

        # ── 35. 保險那一格:出事的時候登記是「正在換名」→ 兩條「停下來了」都要說不確定 ──
        # 換名包在不可中斷的區塊裡,正常跑不到這一格,所以這裡直接擺好登記、
        # 讓清檔那一步丟出錯誤,驗的是 main() 收尾照登記說話的那一段。
        real_cs = globals()['cmd_shrink']
        outs35 = []
        for exc35 in (DataError('假的問題(這是 --selftest 故意製造的)'),
                      OSError(5, '假的讀寫錯誤(這是 --selftest 故意製造的)')):
            def cs_mid_replace(path_, apply_it, _exc=exc35):
                _REPLACED.update(what='shrink', path=path_, phase='replacing')
                raise _exc

            globals()['cmd_shrink'] = cs_mid_replace
            try:
                outs35.append(run_main([big, '--apply']))
            finally:
                globals()['cmd_shrink'] = real_cs
            reset()
        check('保險:出事的時候登記是「正在換名」→ 兩條「停下來了」都說沒辦法確定、'
              '印出 --restore 那一行,不說「一個位元組都沒動」',
              len(outs35) == 2
              and all(r_ == 2 and '沒辦法確定' in o_ and ('"%s" --restore' % big) in o_
                      and '一個位元組都沒動' not in o_ for r_, o_ in outs35),
              '結束碼 %r' % ([r_ for r_, _o_ in outs35],))

        # ── 36. 餌:換名之後複驗沒過,自動還原走到最後換名那一步也失敗 ──
        # 這時候正本還是清過的那一版。還原那一支(_do_copy)沒換成時,登記要收回
        # 「進來之前的樣子」;收成「還沒動」的話,接下來按一下 Ctrl-C,
        # 收尾就會說「你的遊戲檔一個位元組都沒有動到」。
        # 一起驗:訊息自己已經帶著 --restore,整個畫面只印一次。
        calls36, hits36 = [], []

        def vp_second_bad36(check_path, items, raw_):
            calls36.append(os.path.basename(check_path))
            if len(calls36) >= 2:
                return ['  ❌ 假的問題(這是 --selftest 故意製造的)']
            return real_vp(check_path, items, raw_)

        def replace_second_boom(a, b):
            if os.path.basename(os.fspath(b)) == 'stadium.big':
                hits36.append(os.path.basename(os.fspath(a)))
                if len(hits36) >= 2:               # 第一次是清完換上去,第二次是自動還原
                    raise OSError(28, '假裝磁碟滿了(這是 --selftest 故意製造的)')
            return real_replace(a, b)

        globals()['_verify_pack'] = vp_second_bad36
        os.replace = replace_second_boom
        try:
            rc, out = run_main([big, '--apply'])
        finally:
            globals()['_verify_pack'] = real_vp
            os.replace = real_replace
        left = sorted(n for n in os.listdir(d) if n.startswith('.'))
        check('餌:自動還原的換名也失敗 → 結束碼 2,登記還是「已經換成清過的那一版」,'
              '--restore 那一行只印一次,不留暫存檔',
              len(hits36) == 2 and rc == 2 and '自動還原沒有成功' in out
              and _REPLACED['what'] == 'shrink' and _REPLACED['phase'] == 'replaced'
              and out.count('--restore') == 1 and ('"%s" --restore' % big) in out
              and '一個位元組都沒動' not in out and not left,
              'rc=%r 登記=%r/%r --restore 出現 %d 次 留下=%r'
              % (rc, _REPLACED['what'], _REPLACED['phase'], out.count('--restore'), left))
        reset()
        run_main([big, '--restore'])
        if os.path.lexists(bak):
            os.remove(bak)
        reset()
        if open(big, 'rb').read() != raw:
            check('  (餌 36 之後沒能還原回起點,下面的項目不可信)', False)

        # ── 37. 餌:--restore 時 .shrinkbak 是符號連結,指到外面一份完整合法的封裝檔 ──
        # 這條路上只有 _do_copy 那一行擋得住它:備份在、開頭是 BIGF、長度也對得上,
        # 前面幾道把關全過。拿掉那一行,外面那一份就會被拿來蓋掉正本。
        other = os.path.join(outside, '別人的球場.big')
        other_raw = _st_big(other, [('entry', 'z.fsh', b'Z' * 300), ('junk', b'\xCC' * 1500),
                                    ('entry', 'y.fsh', b'Y' * 300)])
        other_sha = hashlib.sha256(other_raw).hexdigest()
        if can_symlink(bak, other):
            before = snap(big)
            rc, out = run_main([big, '--restore'])
            left = sorted(n for n in os.listdir(d) if '.restore-' in n)
            check('餌:--restore 時備份是符號連結(指到外面一份完整的封裝檔)→ 結束碼 2,'
                  '正本與外面那個檔都沒被動到,不留暫存檔',
                  rc == 2 and '備份 是一個符號連結' in out and snap(big) == before
                  and hashlib.sha256(open(other, 'rb').read()).hexdigest() == other_sha
                  and not left, 'rc=%r 留下=%r' % (rc, left))
            os.remove(bak)
        else:
            skip('餌:--restore 時備份是符號連結', '這台做不出符號連結')

        # ── 38. 餌:Ctrl-C 落在換名之前(驗暫存檔的時候)→ 照實說一個位元組都沒動 ──
        # 餌 7 驗的是換名之後的那一句;這一項驗的是最常見的那一種:還沒換上去就按了 Ctrl-C。
        # 收尾把這一句講成「已經換成清過的那一版」、或留下暫存檔沒收,這一項都會紅。
        def vp_ctrl_c(check_path, items, raw_):
            raise KeyboardInterrupt

        before = snap(big)
        globals()['_verify_pack'] = vp_ctrl_c
        try:
            rc, out = run_main([big, '--apply'])
        finally:
            globals()['_verify_pack'] = real_vp
        left = sorted(n for n in os.listdir(d) if n.startswith('.'))
        check('餌:Ctrl-C 落在換名之前 → 結束碼 130,說一個位元組都沒有動到、不叫人 --restore,'
              '正本還是同一個檔、同樣的位元組,暫存檔清掉',
              rc == 130 and '一個位元組都沒有動到' in out and '--restore' not in out
              and '換成清過的那一版' not in out and snap(big) == before and not left
              and _REPLACED['phase'] == 'idle',
              'rc=%r 留下=%r 登記=%r' % (rc, left, _REPLACED['phase']))
        reset()

        # ── 39. 餌:--restore 已經換上去了,最後那一次比對讀不到備份 → 照實說還原已經做完 ──
        # 還原本身做完之後,cmd_restore 會把備份與正本整份讀出來再比一次;那時候讀不到
        # (外接碟被拔掉)走的是 main() 的 OSError 那一條。把「還原那一步已經做完了」
        # 講成「一個位元組都沒動」的話,這一項就會紅 —— 那時候正本其實已經換回備份那一份了。
        with open(big, 'wb') as f:                  # 讓正本跟備份不一樣,還原才有事可做
            f.write(raw + b'\x00' * 8)
        with open(bak, 'wb') as f:
            f.write(raw)
        real_rfb39 = globals()['_restore_from_backup']
        gone39 = bak + '.pulled_out'

        def restore_then_backup_gone(bak_, dst_):
            r_ = real_rfb39(bak_, dst_)             # 真的還原(登記會變成「還原已經做完」)
            os.rename(bak_, gone39)                 # 接著備份「不見了」
            return r_

        globals()['_restore_from_backup'] = restore_then_backup_gone
        try:
            rc, out = run_main([big, '--restore'])
        finally:
            globals()['_restore_from_backup'] = real_rfb39
        if os.path.exists(gone39):
            os.rename(gone39, bak)
        check('餌:--restore 換上去之後、最後比對讀不到備份 → 結束碼 2,說還原那一步已經做完,'
              '不說「一個位元組都沒動」;正本確實已經是備份那一份',
              rc == 2 and '還原那一步' in out and '已經' in out and '一個位元組都沒動' not in out
              and open(big, 'rb').read() == raw,
              'rc=%r 登記=%r/%r' % (rc, _REPLACED['what'], _REPLACED['phase']))
        os.remove(bak)
        reset()

    finally:
        shutil.rmtree(tmp_root, ignore_errors=True)
        _REPLACED.update(what=None, path=None, phase='idle')

    # ── 40. 餌:「-O 拒跑」這道守門自己 ──
    # sys.flags 是唯讀的,同一個行程裡切不了 -O,所以換掉的是 _optimize_level。
    if not _SELFTEST_NESTED:                        # 裡面那一次不要再套一層(見 _SELFTEST_NESTED)
        real_opt = globals()['_optimize_level']
        buf = _io.StringIO()
        globals()['_optimize_level'] = lambda: 1
        _SELFTEST_NESTED.append(1)
        try:
            with _contextlib.redirect_stdout(buf):
                rc = selftest()
        finally:
            globals()['_optimize_level'] = real_opt
            _SELFTEST_NESTED.pop()
        check('餌:假裝在 python -O 底下 → --selftest 拒跑(回 2),而且說得出為什麼',
              rc == 2 and '-O' in buf.getvalue(), 'rc=%r' % (rc,))

    # ── 41. 跑完之後,暫存資料夾真的刪掉了(頁面上寫著「跑完整個刪掉」)──
    check('跑完之後那個暫存資料夾已經整個刪掉', not os.path.exists(tmp_root),
          ('還在:' + tmp_root) if os.path.exists(tmp_root) else '')

    print()
    if fails:
        print('  ❌ %d 項沒過:%s' % (len(fails), ' / '.join(fails)))
        return 1
    if skipped:
        print('  ✅ 跑到的全部通過。另外 %d 項因為這台機器做不到而跳過(上面標 ⏭ 的那幾行),'
              '那幾項等於沒驗到。' % len(skipped))
    else:
        print('  ✅ 全部通過。')
    return 0


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

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