教學 › 球場超過 10MB 就當機
球場超過 10MB 就當機
這是社群傳了二十年的一條規矩。大家的理解是「球場做得太精緻、檔案太大」。 把檔案量開之後,原因有一半不是那樣 —— 兩包「會跳出」的球場裡,大部分的肥肉是沒有人指向的垃圾,一行指令就能清掉; 但也有幾個是真的做得比較精緻,那種一個位元組都省不了。 這一頁教你怎麼分辨,以及兩種各該怎麼辦。
難度
★☆☆ 一行指令
時間
約 5 分鐘
可以還原嗎
可以,一行指令
🛟 動手之前:先完整備份整個遊戲資料夾
把整個遊戲資料夾複製一份到別的地方,
包括裡面存放紀錄的資料夾。紀錄也可能放在遊戲資料夾外面:
「文件」裡的 Documents\MVP Baseball 2005,那一個要另外複製。
怎麼整包複製、紀錄(王朝、名單、設定)的兩個位置各在哪、找不到怎麼找: 看備份與還原 SOP 的「整包複製」。
⚡ 只想趕快做完?照這五步(要先裝好 Python,沒裝的看第 0 課操作五)
- 先做備份。這一課會改檔案。
- 先量,完全唯讀。下載 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:
它印「這個檔很乾淨,清了也省不了什麼」就到此為止,你的球場不是這個原因。 (另外兩句話也是同一個意思:「沒人指到的只有 N 個位元組,清了也省不了什麼」、 「重排之後不會變小 —— 那些沒人指到的位元組排在第一筆資料之前……這個檔不用清」。)python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" - 預覽會變多小
Windows:
python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrinkMac / Linux:
python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink - 確定了才真的清。要看到「複驗:N 個項目全部逐位元組相同,檔頭大小欄正確 ✅」
Windows:
python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink --applyMac / Linux:
python3 -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --shrink --apply - 進遊戲,開那座球場。這一步不能跳。不對就一行還原
Windows:
python -I mvp_shrink_big.py "<遊戲資料夾>/data/stadium/skydnite.big" --restoreMac / 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.big | 3.42 MB | 18.44 MB | 5.4× |
rfksnite.big | 2.88 MB | 14.95 MB | 5.2× |
propnite.big | 3.28 MB | 12.64 MB | 3.8× |
propday.big | 3.26 MB | 12.52 MB | 3.8× |
skydday.big | 3.43 MB | 12.09 MB | 3.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.big | 18.44 MB | 5.20 MB |
| 目錄項目數 | 88 | 88 |
| 每一項的內容(md5 逐項比對) | 88 個全部相同 | |
| 清完之後剩下的非目錄位元組 | 71.9% | 0.11% |
0.11% ——「跟剛安裝好的原版一樣乾淨」。 那 0.11% 是檔頭、目錄本身,以及把每一項對齊到 4 的倍數的補齊位元組。
這跟本站「絕不重新打包」的鐵律衝突嗎
本站其他課一律說「不要整包重新打包,會弄丟目錄沒指到的資料」。這一課做的正好是那件事, 所以要說清楚為什麼這裡可以:
- 那條鐵律的理由是「你不知道孤兒資料有沒有用」。 而本站量到剛安裝好的原版孤兒只有 0.00%(87 個球場檔的中位數,最高 0.01%)—— 證明這個格式本來就不該有孤兒
- 這支腳本不重新產生任何內容,只是把原本的項目照原順序搬近一點。 寫完會把 88 個項目逐位元組跟原檔比對
- 原檔會保留成備份,一行指令還原(本站實測還原後 SHA-256 與原檔相同)
但最後一關還是你。清完之後一定要進遊戲看一次 —— 每個人手上那包不一樣,本站驗得了檔案,驗不了你那一份。
⭐ 2026-08-28:整包清完之後,實機驗過了
上面那些數字都是檔案層級的證明:清完之後 88 個項目逐位元組相同。 但檔案對不代表遊戲吃得下去 —— 那一關本站原本沒辦法自己驗。
現在驗了。把一整包 87 個球場檔全部清過一遍,然後實際進遊戲玩:
| 檔數 | 合計 | 最大的一個 | 超過 10 MB 的 | |
|---|---|---|---|---|
| 清之前 | 87 | 647.6 MB | 15.14 MB | 24 個 |
| 清之後 | 87 | 406.8 MB | 12.74 MB | 2 個 |
| 省下 | — | 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 |
|---|---|---|---|---|---|
| 剛安裝好的原版 | 87 | 3.24 MB | 0.00% | 0.01% | 0 個 |
| 標「不超過 10MB」的安全包 | 87 | 3.24 MB | 0.00% | 0.01% | 0 個 |
| 標「10MB」的那包 | 83 | 4.73 MB | 0.02% | 71.84% | 5 個 |
| 2023 年那包 | 87 | 7.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.big | 2.88 MB | 14.95 MB | 14.95 MB | 0% |
wrignite.big | 3.36 MB | 12.74 MB | 12.74 MB | 0% |
propnite.big | 3.28 MB | 12.64 MB | 12.64 MB | 1.4% |
propday.big | 3.26 MB | 12.52 MB | 12.00 MB | 5.7% |
coornite.big | 3.43 MB | 11.05 MB | 11.05 MB | 0% |
⚠️ 口徑:這張表是把兩包社群球場合起來看,列的是「清完之後仍然超過 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,711 | 3,082,320 | 有 |
| 七個顏色版本 | 3,081,212 | 3,081,172 | 沒有 |
| 第八個(原版備份) | 1,212,711 | 3,082,320 | 有 |
大了 2.5 倍,而它們只改了 34 個項目裡的 1 個。 其餘 33 項跟 EA 的逐位元組完全相同(本站用雜湊逐項比過)。
換句話說:為了換一張貼圖,多帶了 1.87 MB 完全沒必要的東西 —— 不是因為多了內容,是因為存回去的時候沒有把壓縮做回去。
這支腳本幫不上忙:它清的是沒人指到的位元組, 而這個檔沒有孤兒,只是沒壓縮。要救它得重新壓縮,那是另一件事。
⭐ 那八個版本裡有一個叫「原版」的,本站量下來跟剛安裝好的一模一樣 —— 做那個模組的人順手附了還原用的原檔。二十年前的好習慣。
所以「封裝檔為什麼變大」一共有三個答案
| 怎麼認出來 | 這一課有沒有用 | |
|---|---|---|
| ① 孤兒堆積 | 孤兒比例高(幾十 %) | 有,免費省掉 |
| ② 貼圖真的換大了 | 孤兒接近 0,項目數不變但每項變大 | 沒有 |
| ③ 壓縮被丟掉 | 孤兒接近 0,解開後的大小幾乎等於檔案本身 | 沒有,要重新壓縮 |
三種都可以用同一個動作分辨:先量。 腳本印出來的那三個數字(檔案大小、目錄指到的內容、孤兒佔比)就夠了。
本站為什麼把這一段補上,而不是把上面改掉
上面那段推論不是錯的 —— 孤兒真的存在、真的佔 71.9%、清掉真的省 13 MB。 錯的是把它當成唯一的原因。
會犯這個錯是因為第一個量的檔(skydnite.big)剛好是最極端的那個。
從最漂亮的樣本推出通則,是本站反覆踩到的同一個坑。
留著這一段,是要讓你看到修正長什麼樣子。
前置
- 電腦裡有 Python 3
- 先做備份。這一課會改檔案
👉 你要做的事
下載腳本
mvp_shrink_big.py(2,590 行,零相依)
指令要在放腳本的那個資料夾打。瀏覽器下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列(整條會反白),把字刪掉改打 cmd 按 Enter —— 開出來的命令提示字元已經停在 MVP_Tools 裡,後面的指令都在這個視窗打(第 0 課操作一的「資料夾法」)。Mac:打開終端機,先打 cd ~/MVP_Tools 按 Enter。
先量:這個檔裡有多少是沒人指到的
完全唯讀。先知道有沒有得清,再決定要不要動。
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,也不會做備份。
預覽會變多小
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
確定了才真的清
會先備份,寫完把 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
進遊戲,開那座球場
這一步不能跳。本站驗得到「檔案是對的」,驗不到「遊戲讀得動」。
✅ 成功標準
- 「已備份 → ○○.big.shrinkbak」
- 「複驗:N 個項目全部逐位元組相同,檔頭大小欄正確 ✅」
- 進遊戲,那座球場開得起來而且看起來正常
❌ 出錯處理
| 看到什麼 | 怎麼做 |
|---|---|
| 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 複驗那一行: 進遊戲:開得起來 / 開不起來 / 畫面怪怪的
這一課學到的東西可以用在別的地方
「檔案大」跟「內容多」是兩件事。這一課的量法(把目錄裡每一項的長度加起來,跟檔案大小比) 對任何一個封裝檔都適用,不只球場。
如果你手上有哪個模組讓遊戲變慢或不穩,先用這支腳本量一下 —— 先確認問題是什麼,再決定要不要動它。 這也是本站每一課的順序。
📌 重點整理
- 一句話 球場檔變大有三個完全不同的原因,這一課只治得了其中一個(孤兒堆積),所以第一個動作永遠是量,不是清。
- 關鍵數字
skydnite.big檔案 18.44 MB,目錄 88 個項目加起來只有 5.19 MB,13.25 MB(71.9%)是沒有人指向的位元組;清完變 5.20 MB,88 個項目 md5 逐項比對全部相同,剩下的非目錄位元組 0.11%。 - 孤兒是怎麼來的 安全的改法就是把新資料接到檔尾、只改目錄,舊的那份還躺在原地沒人指向。做一個球場模組要來回調整幾十次,每調一次就留下一份屍體。
- 這不是格式的性質 剛安裝好的原版 87 個球場檔,孤兒比例中位數 0.004%、最高 0.006%。EA 出貨的檔幾乎沒有孤兒,所以那 13 MB 是後來被加進去的。
- 最容易踩的雷(本站自己踩了兩次) 一次是從最極端的樣本
skydnite.big推出通則,二次是看到「孤兒 0%」就直接認定「內容變多」。其實還有第三種:存回去時沒有把壓縮做回去。EA 出貨的球場未壓縮內容 0.0%,社群兩包分別是 13.5%(58 MB)與 17.4%(69 MB),但逐檔中位數都是 0%,問題集中在少數幾個檔。 - 那條二十年的規矩只對一半 兩包社群球場合起來看,清完之後
rfksnite14.95 MB、wrignite12.74 MB、propnite12.64 MB、propday12.00 MB、coornite11.05 MB 仍然超過 10 MB,孤兒幾乎都是 0%(最高的propday也只有 5.7%),項目數一樣是 88 個,那種檔案縮不下來。但「縮不下來」不等於沒救:中文版跳出卡住的是遊戲寫死的 64 MB 記憶體池,不是「球場檔不能超過 10MB」那回事,見中文版配大球場就跳出。另外那個社群傳的「安全球場包」,87 個檔跟剛安裝好的官方中文版逐位元組相同,它根本就是原廠檔的備份。 - 安全網 不加參數就是唯讀量測;
--apply會先備份成.shrinkbak,寫完逐項比對位元組,比不過就當場自動還原;--restore一行還原,而且還原本身是先寫暫存檔、比對 SHA-256 才換名(換名之前失敗,正本原封不動),本站實測還原後 SHA-256 與動手前完全相同。整包 87 個球場清過一遍是 647.6 MB 降到 406.8 MB(省 37.2%),超過 10 MB 的從 24 個變 2 個,而且實機玩過照常。(班主任清完之後另外留的那一份是 394.9 MB/0 個,因為裡面另有兩個檔是換成另一包社群球場的版本,不是清出來的 —— 見上面那張表底下的訂正。本站測試機的遊戲資料夾目前放的仍然是未清理的那一份。) - 本站沒驗的 「為什麼檔案大會害到遊戲」在這一課只是推論,這一課沒有量遊戲執行時的記憶體,只能確定清完檔案變回 5.20 MB。(中文版配大球場就跳出那一課後來量了:卡住的是一塊寫死的 64 MB 記憶體池,不是「球場檔不能超過 10MB」那條線 —— 本站把一顆球場清到 8.51 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.
# ─────────────────────────────────────────────────────────