教學 › 介面萬用工具
介面萬用工具 fel_toolkit
前面每一課都配一支專用小程式。這一課給你的是一支通用的 —— 遊戲裡任何一個介面元素,用名字就能找到它、看到它、關掉它。 不用再為每個修改各寫一支程式。
難度
★★☆ 要打幾個指令
時間
第一次約 15 分鐘
可以還原嗎
可以,一行指令
按了下載之後,檔案跑到哪裡去了?指令要在哪裡打?
瀏覽器多半不會問你要存哪裡,它會直接放進「下載」資料夾;請把檔案搬進 MVP_Tools(第 0 課操作六)。
所以命令視窗要從 MVP_Tools 開:
1打開檔案總管,進到「MVP_Tools」
2點一下最上面的網址列(整條會反白),把字刪掉改打 cmd,按 Enter
3開出來的視窗已經停在 MVP_Tools 裡,後面的指令都在這個視窗打
Mac:打開終端機,先打 cd ~/MVP_Tools 按 Enter。
沒照這樣開視窗的話,畫面會出現 can't open file —— 那不是你打錯,是視窗跟檔案不在同一個資料夾。
零相依、預覽不改檔、自動備份與複驗、還原也是原子的(先寫在旁邊、驗過才換上,正本不會先被截斷)、不跟著符號連結走、附完整原始碼。腳本本身採 MIT 授權(條款寫在檔尾),本頁教學文字採 CC BY 4.0。
🛟 動手之前:先完整備份整個遊戲資料夾
把整個遊戲資料夾複製一份到別的地方,
包括裡面存放紀錄的資料夾。紀錄也可能放在遊戲資料夾外面:
「文件」裡的 Documents\MVP Baseball 2005,那一個要另外複製。
怎麼整包複製、紀錄(王朝、名單、設定)的兩個位置各在哪、找不到怎麼找: 看備份與還原 SOP 的「整包複製」。
⚡ 只想趕快關掉某個介面元素?照這五步(要先裝好 Python,沒裝的看第 0 課操作五)
- 用本頁的下載按鈕拿到
mvp_fel_toolkit.py,瀏覽器會把它放進「下載」資料夾,先搬進MVP_Tools(第 0 課操作六)(命令視窗不在那個資料夾的話,後面會出現can't open file,那是本站最常見的第一個卡點)。 - 在 MVP_Tools 開命令視窗:Windows 先把下載的腳本搬進
MVP_Tools(第 0 課操作六),再打開檔案總管進到 MVP_Tools,點一下最上面的網址列,把字刪掉改打cmd按 Enter,後面的指令都在開出來的這個視窗打;Mac 開終端機打這一行
cd ~/MVP_Tools - 用名字把元素找出來,最右邊那一欄會告訴你它現在是開還是關
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --find SPEED - 先預覽,這一步不會改到任何檔案
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --toggle fes_ingameinfobar.fel RATING off - 預覽沒問題的話,同一行最後加
--apply真的寫入,要看到「✅ 複驗通過」
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --toggle fes_ingameinfobar.fel RATING off --apply
後悔的話,這一行還原,位元組完全一致:
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --restore
上面的 python 是 Windows 寫法,Mac 使用者改成 python3,其餘完全一樣(開命令視窗那一步,Windows 與 Mac 已經分開寫)。
想知道為什麼這樣改,往下讀;只想複習,跳到📌 重點整理。
它能做十四件事
| 指令 | 做什麼 | 會改檔嗎 |
|---|---|---|
| --list | 列出封裝檔裡有哪些版面檔、多大、幾行、幾個畫面。預設看的是 data\frontend\ingame.big,要看主選單那一批得自己加 --big data/frontend/frontend.big | 不會 |
| --entries | 列出封裝檔裡的每一個項目,不限版面檔 —— 圖片、模型、球場那些「一個版面檔都沒有」的封裝檔也看得到裡面, 而且會看檔頭認出每個項目是什麼 | 不會 |
| --bigs | 掃出全遊戲的版面檔分別在哪些封裝檔(配 --big 換檔操作;本站測試機的六個封裝檔合計 332 個) | 不會 |
| --find | 掃封裝檔裡的每一個版面檔,找名字含某個字的元素(ingame.big 在本站測試機是 47 個,剛安裝好的原版是 44 個) | 不會 |
| --show | 顯示某個版面檔的內容,可以只看某一個畫面。不指定畫面時預設只印前 80 行,要看完整份就加 --lines 9999 | 不會 |
| --extract | 存成文字檔,用你習慣的編輯器慢慢看。輸出檔只寫檔名(例如 out.txt)的話,會存在命令視窗所在的資料夾(照本頁從 MVP_Tools 開視窗的話,就在 MVP_Tools 裡) | 不動遊戲檔 (另外寫一個純文字檔) |
| --strings | 把字串編號換成真正的文字 —— 不用開遊戲就知道每個元素寫什麼 | 不會 |
| --where | 列出顯示中的元素在畫面上的座標 —— 配合介面座標圖看 | 不會 |
| --toggle | 開關某個元素(加 --apply 才真的寫入) | 會(要 --apply) |
| --cameras | 列出遊戲裡 39 個攝影機的位置與視野角度
(要搭配 --big data/datafile/datafile.big) | 不會 |
| --camera | 看某一個攝影機的全部欄位,不加 --set 就只是印出來 | 不會 |
| --camera --set | 改某一個攝影機 ——
加 --set FOV=32 是預覽,再加 --apply 才寫入。
白名單裡有十個欄位名,但每一台只讓你改其中七個:打擊視角那 32 台是
X/Y/Z/FOV/Pitch/Heading/Roll,守備那張表管的 7 台
(FieldingView1-6 與 HeadViewer)
是把 X/Y/Z 換成 OffsetX/OffsetY/OffsetZ,而且要改的那一格本來就得是數字。
(32 / 7 這兩個數字,本站在九份有攝影機記錄的總設定檔上各量了一次:
六份封裝版 datafile.big、三份散裝版 datafile.txt,
每一份都是 32 台打擊、7 台守備。)
手把手流程在改視角那一課 | 會(要 --apply) |
| --restore | 把封裝檔還原成第一次 --apply 之前的樣子,
只認這支腳本自己建的 .feltoolbak |
會(不吃 --apply,打下去就還原) |
| --restore-datafile | 把總設定檔還原成這支腳本做的那一份備份,
只認 .datafilebak |
會(不吃 --apply,打下去就還原) |
上面十四列就是這支腳本實際派工的十四個模式(main() 分派到十四個
cmd_ 開頭的函式,一個不多一個不少)。最後那兩個還原模式也是「寫入」,它們的工作就是拿備份覆寫遊戲檔,所以不需要也不吃 --apply,打下去就直接還原。
這是好事(後悔的時候少打幾個字),但要知道它沒有預覽這一段。
另外還有一個 --selftest,它不算在上面十四個模式裡 ——
它在最前面就被攔下來,連遊戲資料夾都不用給(所以 --help 的清單裡也看不到它)。
它不碰任何遊戲檔,只在暫存資料夾裡自己造假資料驗自己,跑完會印
「自我測試:28 組全部通過」。想確認手上這一份沒被改壞,跑這一行:
python -I mvp_fel_toolkit.py --selftest
第 15 到 28 組是 2026-09-26 補的。第 15、16 組是陰性對照,證明同一份假資料上正常的指令照常跑得完、寫得進去、還原得回來
(第 16 組每一條會寫檔的路都連改兩次,證明第二次不會重做備份、--restore 回得到第一次動手之前);
第 17 到 28 組是餌,跑的時候每組後面會印「(N 個餌)」,十二組加起來 89 個。
一個餌只放一個讀者常撞到的毛病
(路徑給錯、檔名打錯、封裝檔被截斷、字串表被截斷、備份只剩半截、寫回去之後讀出來對不上、封裝檔沒有讀取權限、資料夾不能寫、按了 Ctrl-C……),
要被對的那一道守門擋下來、印出那一道自己的那句話。這 89 個驗的嚴格程度不一樣:
65 個照讀者打指令的樣子跑、在寫入之前就擋下來(或是還沒動手就按了 Ctrl-C),還要證明指令裡那個資料夾每一個檔一個位元組都沒變、也沒多出檔案;
14 個是寫進去之後才驗得出來的(例如讀回來跟預期不一樣,或是檔案換過之後才按 Ctrl-C),要證明腳本會停下來叫你還原,
而且照著還原真的回到動手之前;5 個驗只讀不寫的指令遇到壞掉的資料照樣跑完、不噴一整片英文、檔案一個位元組都沒變 ——
--entries 遇到解不開的項目照樣列完、把它標出來(2 個),
--strings 遇到字串表被截斷或沒有讀取權限時照樣跑完、說它讀不到字串表,
--where 遇到座標那幾欄不是數字的行就跳過那一行、照樣列完(第 28 組的 3 個);
剩下 5 個比較鬆 ——
命令列走不到的三道(改攝影機的行數那一道、還原最底層的兩道)直接餵那個函式、驗那一句話,
還原那兩道另外驗要還原的目標沒被動到;「還原之後跟備份對不上」那兩個只驗結束碼與那一句話。
第 27 組的 4 個另開一個 Python 從命令列真的跑,驗你真的會看到的那一層:出錯只印一句「❌ …」、不會噴一整片英文,
資料夾不能寫時講人話,按了 Ctrl-C 照實說有沒有動到檔案(Ctrl-C 不是真的按,是讓 Python 在那一刻丟出按下 Ctrl-C 時會丟的同一個例外)。
沒有讀取權限(封裝檔兩個、字串表一個)、資料夾不能寫(一個)那 4 個,在 Windows、用系統管理員身分跑、或是檔案系統不記權限時會跳過,那一組後面會照實印「跳過 N 個」和原因。
前 14 組是之前就有的測試,裡面也有餌(例如符號連結、還原到一半斷掉),沒有算進這 89 個。
量法(2026-09-26):把腳本裡條件成立就讓它停下來的守門逐一拆掉再跑自我測試,
這個量法數得到的 64 道裡,拆掉會變紅的是 64 道(2026-09-24 量的時候是 2 道,那時還沒補這些餌);
try/except 寫法的守門另外量(接到錯誤之後丟出新的錯誤、結束程式,
或是直接回傳一個值的那一種,例如字串表讀不到就當成沒有字串表),15 道裡拆掉會變紅的是 12 道
(字串表讀不到、字串表被截斷、座標那幾欄不是數字這 3 道,是補了第 28 組才會紅的)。
這不等於每一道守門都有餌:還原時驗語系檔、執行檔備份的那幾道,這支工具的兩個還原指令根本走不進去,沒有放餌,
拆掉照樣全綠的 6 道加 2 道都在這裡;另外 1 道要備份在讀的途中被別的程式改掉才走得到(推論,沒有實測),也沒有放餌。
「本身不停下來、只決定要不要做某件事」的守門這兩個量法都數不到,只挑了讀者會踩到的幾道補餌(例如「備份已經有了就不再備份」),其餘沒有逐一量。
前置
👉 你要做的事
確認腳本讀得到你的遊戲。純看,不動手。
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --list
封裝檔內共 47 個項目,其中 47 個版面檔:
檔名 壓縮後 解壓後 行數 畫面
----------------------------------------------------------------
...
fes_hudleft.fel 7140 77059 1175 26
fes_hudright.fel 8377 84907 1342 38
fes_ingameboxscore.fel 7464 55952 734 1
...
合計 22516 行
元素名稱通常是全大寫英文。想關掉什麼,先猜它可能叫什麼。
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --find SPEED
找到 61 個元素:
版面檔 行號 指令 名稱 顯示
------------------------------------------------------------
fes_ingameboxscore.fel 482 TX SPEED 開
fes_ingamecontrolcfg.fel 63 TX SPEED 開
fes_ingameinfobar.fel 759 TX SPEED 關
fes_ingameoverlay.fel 127 TX PITCHSPEED 開
...
分佈: ingameinfobar×6, ingameroster×6, ingamepausemenu×5, ...
最右邊那一欄就是答案
開 代表這個元素現在會顯示,關 代表已經被關掉了。
注意 fes_ingameinfobar.fel 那幾個 SPEED 在你這份檔案裡是關的。
你可以把它們打開來看看原本長什麼樣。
這裡原本寫「那是 EA 自己關掉的」,本站並沒有量過那句話。
配方那一課後來真的拿剛安裝好的原版比了另一個檔,
結果正好相反:原版是開著的,關掉它的是這台機器上裝過的模組。而
fes_ingameinfobar.fel 本身就不在 EA 原版的 ingame.big 裡,
所以它裡面的開關不會是 EA 的決定。
- 試短一點的關鍵字(
--find SCORE而不是--find SCOREBOARD) - 名稱是英文,不是中文
- 還是找不到的話,用
--show把可能的檔案翻一遍 —— 但它預設只印前 80 行,上千行的版面檔翻不到底,記得加--lines 9999
確認你找到的是不是你想的那一個。可以只看某一個畫面,不用捲一千多行。
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --show fes_ingameoverlay.fel --screen INGAMEOVERLAY
fes_ingameoverlay.fel / 畫面 INGAMEOVERLAY(第 2–194 行)
2 SC:INGAMEOVERLAY,1,0,0,0,640,480,1,1,1,1,0,0,-1,0,
3 LF:frk12_en.ffn
4 LF:twc16_en.ffn
5 LF:twc14_en.ffn
6 LS:ingameov
...
✕ = 這一行的顯示開關是 0(關閉)
錯誤訊息會直接列出這個檔有哪些畫面,照著挑一個就好。
格式是「版面檔 元素名 on 或 off」。同名的會一起改,這通常正是你要的。
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --toggle fes_ingameinfobar.fel RATING off
【預覽】fes_ingameinfobar.fel 裡的「RATING」
找到 6 行:
758 TX 開 → 關
769 TX 開 → 關
779 TX 開 → 關
788 TX 開 → 關
797 TX 開 → 關
805 TX 開 → 關
會改動 6 行。
以上只是預覽,沒有改到任何檔案。
為什麼是 6 行不是 1 行
同一個元素在一個檔案裡通常有好幾份。上面這 6 行是本站測試機那份 ingame.big
(2,665,562 bytes、47 個項目)量到的:GR:BASES 底下本壘、三壘、二壘各帶一份,
而 GR:FIRSTBASE 這個群組被宣告了兩次(第 782 行與第 791 行),
第一次那組帶一份、第二次那組帶兩份,合計 6 份。
換一份檔案就不一定是 6 行。本站另一份社群名冊模組的 ingame.big
(380,483 bytes、48 個項目)就剛好是四個壘包各一份。這支工具不寫死數量,
掃到幾個就改幾個,以預覽實際印出幾行為準。
只改一份的話,其他情境還是會冒出來。 這支工具預設一次全改,就是為了避免這個坑。 詳見 FEL 版面語法的「同一個東西會出現很多次」。
同一行指令最後加 --apply。腳本會自動備份、寫入、複驗。
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --toggle fes_ingameinfobar.fel RATING off --apply
已備份 → ingame.big.feltoolbak
已寫入:新資料接在第 2665562 個位元組,只改了目錄 8 bytes + 檔頭 4 bytes
複驗:47 個項目全部讀得回 · 目標元素 0 個未套用
複驗:檔頭寫的大小 2750503 = 實際檔案大小 ✅
✅ 複驗通過
檔案會變大,這是對的
它不是「改掉原本那一段」,而是把新資料接到檔案最後面, 再把目錄改成指向新位置。原本的資料一個位元組都沒動。
所以檔案會變大(實測 2,665,562 → 2,750,503)。 這是這種封裝檔唯一安全的改法, 理由見 BIGF 封裝格式。
每按一次 --apply 就再接一次,所以體積會一路長。
同一個元素開開關關也一樣:本站測試機上連做兩次是
2,665,562 → 2,750,503 → 2,835,444(每次固定多 84,941 bytes),
而 --list 一直是 47 個項目、合計 22516 行 ——
內容不會累積,體積會。不想留著就跑 --restore,會回到原本的大小。
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --restore
已從備份還原 → ingame.big
位元組完全一致:✅ 是
可以拿它做什麼
學會這支工具之後,本站其他的介面教學你都可以自己做。幾個起點:
| 你想做的 | 試試看 |
|---|---|
| 關掉跑壘者速度數值 | --toggle fes_ingameinfobar.fel RATING off |
| 找出所有跟分數有關的元素 | --find SCORE |
| 知道某個東西在畫面哪個位置 | --where fes_ingameinfobar.fel |
| 找出所有球數顯示 | --find COUNT |
| 看看這份檔案裡哪些東西是關的 | --find SPEED(看「顯示」欄是「關」的) |
| 把被關掉的東西打開來看 | --toggle 〈檔名〉〈名稱〉 on |
想知道哪個畫面在哪個檔,看 47 個版面檔對照表(可以直接搜尋)。
✅ 成功標準
- 步驟 6 印出「✅ 複驗通過」
- 步驟 6 印出「目標元素 0 個未套用」
- 遊戲資料夾裡多了
ingame.big.feltoolbak - 再跑一次
--find,那些元素的「顯示」欄變成你要的狀態 - 進遊戲後畫面確實改變
❌ 出錯處理
| 畫面上的訊息 | 原因 | 你要做的 |
|---|---|---|
| can't open file '…\mvp_*.py' | 命令視窗跟腳本不在同一個資料夾,或檔名對不上(瀏覽器可能在檔名後面加了 (1))。
這是本站最常見的第一個卡點。 |
打開放著那個 .py 的資料夾(照第 0 課操作六搬過的話就是 MVP_Tools),在網址列打 cmd 按 Enter,用新開的視窗再跑一次(Mac 用 cd 切過去,例如 cd ~/MVP_Tools);檔名多了 (1) 就改成跟指令裡一模一樣 |
| 檔頭前四碼不是 BIGF | 指到的不是封裝檔 | 確認遊戲資料夾對不對 |
| 封裝檔裡沒有 XXX.fel | 檔名打錯 | 先跑 --list 抄檔名 |
| 裡面沒有畫面「XXX」 | 畫面代號打錯 | 錯誤訊息會列出有哪些 |
| 找不到可開關的元素「XXX」 | 名字錯,或那個指令沒有顯示開關 | 先用 --find XXX 確認它在哪個檔 |
| 第三個參數要是 on 或 off | 打成別的字 | 只能是 on 或 off |
| 複驗不通過 | 寫入結果與預期不符 | 立刻 --restore,然後回報 |
| 找不到備份 | 還沒 --apply 過 | 本來就沒改過 |
| 位元組完全一致:❌ 否 | 還原之後又有別的東西動了這個檔(幾乎不會發生 —— 換名之前腳本已經比過雜湊) | 腳本會印這一行之後以結束碼 1 停下來,不會假裝成功。.feltoolbak 還在原地沒被動過,先手動比對兩個檔再決定要不要重跑一次 --restore |
| 已中斷。 ⚠ 下面這些檔已經換過了… |
寫入或還原途中你按了 Ctrl-C | 照它列出來的檔跑一次 --restore(改攝影機那條路是 --restore-datafile)。訊息只寫「遊戲檔一個位元組都沒有被動到」的話就什麼都不用做 |
| 沒有權限寫入:[Errno 13]… | 遊戲裝在 C:\Program Files 這種受保護的資料夾(那正是這個遊戲的預設安裝位置) |
不要用系統管理員身分跑,把整個遊戲資料夾複製到桌面,命令裡的路徑換成那一份,也從那一份開遊戲。(從複製那份開遊戲時,存檔會放在哪裡,本站還沒在 Windows 實測。)遊戲檔沒有被改到。 |
| 這份備份的結尾不是換行 這份備份的最後一行不是一筆完整的記錄 |
上次備份被中斷,那份 .datafilebak 是半截的(走 --restore-datafile 那條路才會遇到) |
改用你自己另外留的備份,不要硬還原 |
| 要還原的目標開頭不是 BIGF | 路徑指錯了,或那個遊戲檔本身已經壞到連檔頭都不是封裝檔 | 訊息會把兩種可能分開列出來,照著分辨;確定是第二種的話,把壞掉的檔改名留著,再把 .feltoolbak 複製成原本的檔名 |
一行就能還原
python -I mvp_fel_toolkit.py "你的遊戲資料夾" --restore
備份是第一次 --apply 時建立的,之後再改幾次都不會覆蓋它。
📋 卡住的話,照這個格式回報
作業系統:Windows 11 / Windows 10 / macOS ___
Python 版本:(python --version 印出來的那行)
卡在哪一步:步驟 ___
我打的指令:(整行複製貼上)
畫面印出什麼:(整段複製貼上)
--list 印出幾個版面檔:___
我要改的元素叫什麼:___
📋 回報範本
卡住或做成了,複製下面這段填一填:
工具 status: ✅ / ❌
我用的是哪一個模式(--list/--entries/--find/--cameras…): ___
指令整行(把路徑換成 X:\...): ___
印出來的前幾行: ___
異常訊息(整段複製): ___
📌 重點整理
- 一句話 一支零相依的腳本,用元素名字就能在遊戲介面裡找到它、看到它、開關它,不必再為每一個修改各寫一支程式。
- 十四個模式,四個會動到遊戲檔
--list/--entries/--bigs/--find/--show/--strings/--where/--cameras/--camera(不加--set)這九個一個位元組都不動;--extract不動遊戲檔,只另外寫一個純文字檔;--toggle與--camera --set要加--apply才真的寫入;--restore與--restore-datafile本來就是要拿備份覆寫遊戲檔的,不吃--apply,打下去就直接還原。 - 先找再改
--find一次掃封裝檔裡的每一個版面檔並印出「顯示」欄(ingame.big在本站測試機是 47 個,剛安裝好的原版 44 個);--strings把字串編號換成真正的文字,不用開遊戲就知道每個元素寫什麼;--bigs掃得到全遊戲的版面檔分佈在哪些封裝檔(本站測試機的六個封裝檔是 332 個,剛安裝好的原版是 329 個)。你自己跑出來的數字可能比較大 —— 它掃的是資料夾裡每一個封裝檔,你放在同一個資料夾裡的備份.big也會被算一次。 - 同名的會一起改,這通常正是你要的
fes_ingameinfobar.fel的RATING,在本站測試機那份ingame.big(2,665,562 bytes、47 個項目)上預覽會印「找到 6 行」:本壘、三壘、二壘各帶一份,而GR:FIRSTBASE這個群組被宣告了兩次,第一次那組帶一份、第二次那組帶兩份。這不是通則,本站另一份社群名冊模組的ingame.big(380,483 bytes、48 個項目)就剛好是四個壘包各一份。只改一份的話,其他情境還是會冒出來。 - 檔案變大是對的 寫入是把新資料接到檔案最後面,只改目錄 8 bytes 加檔頭 4 bytes,原本的資料一個位元組都沒動(實測 2,665,562 變成 2,750,503)。這是這種封裝檔唯一安全的改法。每按一次
--apply就再長一次(連做兩次是 2,665,562 → 2,750,503 → 2,835,444),累積的是體積不是內容;--restore會回到原本的大小。 - 最容易踩的雷
can't open file幾乎都不是你打錯,是命令視窗跟.py不在同一個資料夾(從放著.py的那個資料夾的網址列打cmd開視窗,照第 0 課操作六搬過的話就是 MVP_Tools);--toggle的第三個參數只能是on或off;找不到元素時先用--find確認它到底在哪個檔。 - 安全網 第一次
--apply會在旁邊建ingame.big.feltoolbak,之後再改幾次都不會覆蓋它,所以--restore永遠回到你第一次動手之前。看到「複驗不通過」立刻--restore再回報。還原本身也是原子的:先寫在旁邊一個名字隨機的暫存檔、跟備份比對過才換上,中途失敗或按了 Ctrl-C,正本都不會變成半截;還原後印「位元組完全一致:❌ 否」的話腳本會以結束碼 1 停下來,備份還在原地。 - 本站沒驗的 頁尾列出的驗證全在檔案層面(11 種畸形輸入皆不中斷、還原後位元組完全一致、還原途中失敗時正本原封不動、暫存檔不跟著符號連結走、重複執行不會累積改動、寫入後複驗 47 個項目仍可讀)。成功標準最後一條「進遊戲後畫面確實改變」是留給你自己確認的那一項。
想更進一步
- 七分鐘版 —— 只想關掉一個畫面元素,看這個
- 介面實例集 —— 十則現成配方,複製指令就能用
- FEL 版面語法速查 —— 每個指令、每個參數是什麼
- 47 個版面檔對照表 —— 想改哪塊畫面,先找對檔案
- 版面檔裝在哪些封裝檔 —— 不只 ingame.big,全遊戲 332 個
- 介面座標圖 ——
--where印出來的座標畫成圖長什麼樣 - 關掉跑壘者速度 —— 同一件事的專用腳本版,另有全手動版
- BIGF 封裝格式 —— 為什麼「接到檔尾」是唯一安全的改法
這支腳本在做什麼
不看程式碼也能知道它怎麼跑。它先把整個封裝檔讀進記憶體,照檔頭後面那份目錄
找出裡面每一個項目在哪裡、多長;接著把每一個 .fel 版面檔用 EA 的 QFS 解壓,
解出來的其實是純文字,一行一個介面元素。
之後你下的每一個指令都只是在這份文字上做事:--find 比對名字、
--strings 去語系檔把編號換成真正的字、--where 讀每一行第 4 到 7 個欄位當座標。
只有 --toggle 與 --camera --set 會走到寫入那一條路,而且要加
--apply:先備份,把新資料接到檔案最後面,只改目錄那 8 個位元組加檔頭 4 個位元組,
然後立刻重讀一次驗給你看。
| 哪一段 | 做什麼 | 為什麼要有它 |
|---|---|---|
main() |
讀參數,決定跑哪一個模式,回傳結束碼 | 判斷順序是有講究的:--bigs 根本不需要封裝檔路徑,
--entries 與攝影機模式排在「這個檔裡有沒有版面檔」那道關卡之前。
不然圖片、球場、總設定檔這些一個版面檔都沒有的封裝檔,會在門口就被擋掉。 |
load_big() + list_entries() |
讀進封裝檔,解析 BIGF 目錄,回傳每一項的名字、位移、長度, 外加那一項的目錄欄位在檔案裡的位置 | 記住目錄欄位的位置,寫回去時就只要改那 8 個位元組。 不重寫整份目錄,才不會把「目錄指不到但還躺在檔案裡」的舊資料弄丟。 |
qfs_decompress() |
解開 EA 的 QFS 壓縮(社群又叫 RefPack),認的是第 2 個位元組 FB |
版面檔在封裝檔裡是壓縮的,不解開就只是一堆亂碼。 解壓時同時盯著「檔案自己宣稱的大小」,壞掉或被動過手腳的檔才不會把記憶體吃光。 |
fel_texts() + fel_lines() |
把解壓後的文字逐行拆成(行號、縮排、指令、名稱、參數) | 版面檔沒有結束標記,階層完全靠縮排,所以縮排要留著。 看不出指令的行照樣產出,行號才不會跳號,而行號正是你拿去對照的東西。 |
visibility_of() |
讀出一行的顯示開關:1 開、0 關、
其他一律當成「這一行沒有開關」 |
整支工具敢不敢動一行,就看這一格。 認錯欄位就會把座標或顏色當成開關寫下去,那就是壞檔。 |
cmd_find() |
掃過全部版面檔,找名字含關鍵字的元素,並印出它們的分佈 | 整支工具的入口。你只知道畫面上有個東西叫 SPEED,
得先知道它在哪一個檔的第幾行,才有下一步。 |
load_loc() |
讀 .LOC 語系檔,把版面檔上的字串編號換成真正的文字 |
版面檔上只寫編號。有這張表才能不開遊戲就認出
「這一格就是畫面上那句話」。四個候選檔名都試,因為
EA 官方繁體中文版把中文放在 JPN 那一對裡,ENG 根本不存在。 |
cmd_where() |
列出顯示中的元素在 640x480 畫面上的位置與所屬群組 | 用一個堆疊追縮排,才知道每個元素被包在哪幾層群組裡。 座標是畫面的絕對值,不是相對於上層群組,所以印出來可以直接對照 介面座標圖。 |
camera_table() |
從總設定檔認出每一台攝影機,配對到「往前最近的那一張欄位名稱表」 | 這個檔裡不只一張名稱表,打擊視角與守備視角的編號完全不同。 套錯表就會把守備視角的結構欄位當成座標寫掉,而且工具自己還會回報成功。 |
find_targets() + set_visibility() |
挑出「叫這個名字、而且真的有開關」的每一行,只把第 1 個參數換掉 | 同一個元素常常四個壘包各一份,只改一份的話其他情境還是會冒出來。 換值時把整行拆開再接回去,連縮排都照原樣還原,不去碰旁邊的座標與顏色。 |
qfs_compress_literal() |
把改好的文字壓回合法的 QFS,不做字串比對,全部用原樣段編碼 | 瞬間完成,而且不可能壓錯。 代價是檔案會變大,但因為新資料是接在檔尾的,大一點沒有影響。 |
_atomic_copy()、_restore_from_backup()、
_do_copy()、cmd_restore() |
備份、驗備份、還原 | 備份先寫在同一個資料夾裡一個名字隨機、別人佔不到的暫存檔,
寫完才改名,中途斷掉不會留下半截的 .feltoolbak。
還原前先擋掉明顯壞掉的備份,因為半截的備份蓋回去,會把本來好好的遊戲檔吃掉。
還原本身走同一套:先寫在旁邊、把寫出來的內容跟備份比對過,
都對了才換名 —— 所以正本不會像一般的複製那樣先被截成 0 再慢慢寫回去。
遊戲檔或備份檔是符號連結的話直接停下來,不跟著它走。 |
十四個模式裡有九個從頭到尾一個位元組都不寫;--toggle 與 --camera --set 沒加 --apply 也只是預覽,
而 --restore 與 --restore-datafile 的工作就是拿備份覆寫遊戲檔,那兩個不吃 --apply。
做不到的事寫在腳本開頭:只能改「顯示開關」那一欄,座標、顏色、字型、文字內容都改不了,
也不會重新打包封裝檔。至於改完在遊戲裡長怎樣,本站沒驗,那一步得你自己開遊戲看。
完整原始碼
下面就是你剛才下載的那一支,一個字都沒有不同(本站有自動檢查在守這件事)。不必下載也能先看過一遍。
展開 / 收合完整原始碼(3256 行,零相依)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_fel_toolkit.py — MVP Baseball 2005 介面版面檔萬用工具
一支腳本搞定:列出、搜尋、檢視、取出、開關任何介面元素。
不用再為每一個修改各寫一支程式。
═══ 這支在做什麼 ═══════════════════════════════════════════════
遊戲的每一塊介面(比賽中的 HUD、選單、轉場畫面)都寫在 .fel「版面檔」裡。
版面檔用 EA 的 QFS 壓縮,裝在 data/frontend/*.big 這種封裝檔裡面。
這支腳本把它解出來當**純文字**讀,讓你用元素的名字(例如 SPEED、RATING)
就能找到它在哪一個檔的第幾行、它在 640x480 畫面上的哪個位置、
它顯示的是哪一句話,必要時把它關掉。
順便也讀寫總設定檔 datafile.txt 裡的攝影機(視角座標與視野角度)。
吃什麼(輸入)
· 第一個參數:遊戲資料夾(裡面要看得到 data 這個子資料夾)
· --big 指定要處理哪一個封裝檔,預設 data/frontend/ingame.big
· 字串表 data/FEENG.LOC、IGENG.LOC(英文版與多數社群中文化模組),
或 data/FEJPN.LOC、IGJPN.LOC(EA 官方繁體中文版把中文放在日文槽位)
· 攝影機模式吃 data/datafile/datafile.big;官方中文版沒有那個封裝檔,
改吃散裝的 data/datafile/datafile.txt
吐什麼(輸出)
· 十四個模式裡有九個只把表格印到畫面,遊戲檔一個位元組都不寫:
--list、--entries、--bigs、--find、--show、--strings、--where、
--cameras、--camera(不加 --set)
· --extract 不動遊戲檔,但會另外寫一個純文字檔(既有的檔不覆蓋,會拒絕)
· --toggle 與 --camera --set 加了 --apply 才會改遊戲檔;沒加只印預覽
· --restore 與 --restore-datafile 是例外:它們的工作就是拿備份覆寫遊戲檔,
所以「沒加 --apply 只預覽」對這兩個不成立,打下去就直接還原
常用流程(先看,再改,最後才 --apply):
列出封裝檔裡有哪些版面檔
python3 -I mvp_fel_toolkit.py "<遊戲資料夾>" --list
找出名字含某個字的元素(會掃封裝檔裡的每一個版面檔)
python3 -I mvp_fel_toolkit.py "<遊戲資料夾>" --find SPEED
看某個版面檔的內容
python3 -I mvp_fel_toolkit.py "<遊戲資料夾>" --show fes_hudleft.fel
python3 -I mvp_fel_toolkit.py "<遊戲資料夾>" --show fes_hudleft.fel --screen HUDLEFT
把整個版面檔存成文字檔(唯讀,方便你用編輯器慢慢看)
python3 -I mvp_fel_toolkit.py "<遊戲資料夾>" --extract fes_hudleft.fel out.txt
關掉/打開某個元素(先預覽,確定了再加 --apply)
python3 -I mvp_fel_toolkit.py "<遊戲資料夾>" --toggle fes_ingameinfobar.fel RATING off
python3 -I mvp_fel_toolkit.py "<遊戲資料夾>" --toggle fes_ingameinfobar.fel RATING off --apply
還原
python3 -I mvp_fel_toolkit.py "<遊戲資料夾>" --restore
安全網
· 預設唯讀:--toggle 與 --camera --set 沒加 --apply 一律只預覽
(--restore 與 --restore-datafile 例外:它們就是來覆寫的,不吃 --apply)
· 第一次 --apply 先做備份(<封裝檔>.feltoolbak/<目標>.datafilebak),
之後再改幾次都保留最早那一份,所以 --restore 永遠回到你動手之前
· 備份、寫入、還原三件事都是原子的:先在同一個資料夾開一個名字隨機的
暫存檔(別人先佔不到)、寫完 fsync 落地、驗過內容,最後才改名換上。
中途斷掉不會留下半截檔,遊戲正本也不會先被截成 0 bytes
· 還原前先擋掉明顯壞掉的備份(見 _restore_from_backup 的五道把關;
散裝的 datafile.txt 另外再驗「結尾是換行、最後一行是完整記錄」)
· 寫入採 append:新資料接到檔尾,只改目錄 8 bytes + 檔頭 4 bytes,
原本的位元組一個都不動。就算新資料是壞的,舊資料還躺在檔案裡
· 寫完立刻重讀複驗;對不上就丟例外(結束碼非 0)並叫你 --restore
· 不跟著符號連結(symlink)寫:遊戲檔、備份檔、--extract 的輸出檔
是連結就停下來,不會在你沒說要動的地方動手
· 換名那一步不會被 Ctrl-C 切成兩半:中斷時程式自己知道檔案「還沒動」、
「正在換」還是「已經換過」,會照實講,不會叫你自己回頭猜
· 自我測試(不需要遊戲資料夾,也不碰任何遊戲檔):
python3 -I mvp_fel_toolkit.py --selftest
做不到的事(先講清楚,省得你找)
· 只能改「顯示開關」那一欄(元素名稱後面第 1 個參數)。
座標、顏色、字型、文字內容都改不了
· 只有兩道關卡都過的行能開關:指令要在 TOGGLEABLE 那張表裡,
而且那一行的第 1 個參數真的讀得到 0 或 1(見 visibility_of)
· VR / LS / LF / END 冒號後面只有一格,沒有第 1 個參數;TS 那一格放的是
空白,SF 放的是版面檔檔名;KA / VS / SC 那一格是 0 或 1,但本站沒有
獨立證據證明它就是顯示開關,所以一律不放行。TOGGLEABLE 裡的 SE 與 TE
同樣沒有那一欄,是那張表列錯了,被第二道關卡擋著,寫不下去
· 不能新增或刪除行;行數變了就中止
· 不會重新打包封裝檔。重新打包會丟掉目錄指不到的資料,
那是本專案的鐵律,這支腳本從頭到尾只用 append
· 壓縮用的是「不做字串比對的合法編碼」,所以檔案會變大(解出來一樣正確)
· 關掉父群組可能被遊戲程式在執行時覆寫回來。
要關單一元素,改它自己那一行
· 攝影機只放行 X / Y / Z / FOV / Pitch / Heading / Roll /
OffsetX / OffsetY / OffsetZ,而且那一格現在必須本來就是數字;
你給的新值也必須是一般的十進位數字(nan / inf 這類寫法一律拒絕)
· 不驗證「改完在遊戲裡長怎樣」。檔案層面全過不代表畫面對了,
那一步得你自己開遊戲看
零相依:只用 Python 3.7+ 內建功能。
自包含:整支腳本就是這一個檔。
授權: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 shutil
import signal
import struct
import hashlib
import argparse
import tempfile
import collections
# ── 備份的原子性(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 只看開頭,看不出後面少了多少。
# ── 猜得到的暫存名 + 符號連結(2026-09-05 第二輪唯讀稽核抓到)──────────
# 上面那個修法(先寫暫存檔再改名)方向是對的,但暫存檔叫 <目的檔>.part ——
# **名字猜得到**。有人先在那個名字上放一個指向資料夾外面的符號連結,
# shutil.copy2 / open(...,'wb') 會順著連結把外面那個檔截斷;
# 後面的 os.replace 只換掉連結本身,傷害在那之前就已經造成了。
#
# ⚠️ os.path.exists() 對「指向不存在目標的符號連結」回傳 False,擋不住這件事。
# 要用 os.path.islink / os.path.lexists —— 它們看的是連結本身。
#
# 修法兩件:(1) 暫存檔一律用 tempfile.mkstemp 開在同一個資料夾裡,
# 名字是隨機的,而且 mkstemp 內部用 O_CREAT|O_EXCL,別人先佔就開不起來;
# (2) 遊戲檔與備份檔本身是符號連結就直接停下來,不跟著它走。
# ── 換名那一刻的 Ctrl-C(2026-09-06 第三輪唯讀稽核 🔴 A-5)──────────────
# 上一版是「先把路徑記進 _REPLACED,再 os.replace,只有 OSError 才把記錄拿掉」。
# 方向是對的(寧可多說「換過了」),但它把每一次「其實還沒換」的中斷也講成
# 「換過了」,而且中間那一格沒有名字 —— 使用者只看得到「動了」跟「沒動」兩種話。
#
# 現在是三態:
# 還沒動 → 兩張表都是空的,訊息說「一個位元組都沒有被動到」
# 正在換 X → X 在 _REPLACING 裡,訊息說「中斷時正在替換 X」
# 已換 X → X 在 _REPLACED 裡,訊息說「已經換過了」並印還原指令
# 而「os.replace + 登記」這兩行被 _NoInterrupt 包起來,中間收到 Ctrl-C 會先記著、
# 離開這一段才丟出來 —— 所以收尾看到的登記一定跟磁碟上的狀態一致,
# 「正在換」那一格幾乎不會被踩到,它是最後一道保險。
_REPLACED = [] # 已經真的換掉的檔(給檔尾 Ctrl-C 的訊息用)
_REPLACING = [] # 正在換、還不知道換成功沒有的檔(同上)
class _NoInterrupt(object):
"""把「os.replace + 登記」包起來:這段期間收到 Ctrl-C 先記著,離開之後再照常丟出。
這樣 KeyboardInterrupt 的收尾看到的登記一定跟磁碟上的狀態一致。
包起來的只有兩行,不會讓使用者覺得「按了沒反應」。
"""
def __enter__(self):
self._pending = False
self._old = None
try:
self._old = signal.signal(signal.SIGINT, self._remember)
except (ValueError, OSError): # 非主執行緒等情況:退回原本行為,不會更糟
self._old = None
return self
def _remember(self, signum, frame):
self._pending = True
def __exit__(self, exc_type, exc, tb):
if self._old is not None:
signal.signal(signal.SIGINT, self._old)
if self._pending and exc_type is None:
raise KeyboardInterrupt
return False
def _replace_and_record(tmp, target, record=True):
"""換名 —— 這是整支腳本唯一會動到既有檔案的一步,所以只寫在這裡一個地方。
順序:先登記「正在換」→ 不可中斷地(os.replace + 改登記)→ 登記「已換」。
os.replace 是原子的:丟 OSError 就是一個位元組都沒換,「正在換」那筆可以拿掉。
record=False 用在「建立備份檔」那一次:那個名字本來不存在,換上去不會蓋掉
使用者的東西,不該讓 Ctrl-C 的訊息叫他去 --restore 一個備份。
"""
if record:
_REPLACING.append(target)
try:
with _NoInterrupt():
os.replace(tmp, target)
if record:
_REPLACING.remove(target)
_REPLACED.append(target)
except OSError:
if record and target in _REPLACING:
_REPLACING.remove(target) # 換名確定沒發生
raise
def _refuse_symlink(path, what):
"""目的檔是符號連結就停下來 —— 不跟著它寫過去。
為什麼不是「跟著連結走就好」:這支腳本的備份與還原都以檔名為單位
(<封裝檔>.feltoolbak 就放在遊戲檔旁邊)。連結指到哪裡不在我們的掌握裡,
寫過去等於在使用者沒說要動的地方動手,而備份還留在這一邊,對不回來。
"""
if os.path.islink(path):
raise DataError(
'%s是一個符號連結(symlink):%s\n'
' 本工具不跟著連結寫過去 —— 連結指向的檔案不在你要改的資料夾裡,\n'
' 萬一要還原也對不回來。請直接對真正的那個檔案跑一次。'
% (what, path))
def _sha256(path):
"""整份檔案的 sha256。
還原時拿它做「逐位元組相同」的判斷:結果跟一個一個位元組比一樣,
但不必把兩份檔同時讀進記憶體(遊戲的封裝檔可以到一百多 MB)。
"""
h = hashlib.sha256()
with open(path, 'rb') as f:
for blk in iter(lambda: f.read(1 << 20), b''):
h.update(blk)
return h.hexdigest()
def _mkstemp_beside(path, tag):
"""在 path 所在的**同一個資料夾**開一個別人佔不到的暫存檔,回傳 (fd, 路徑)。
同一個資料夾是必要條件:os.replace 只有在同一個檔案系統上才是原子的,
寫到 /tmp 再搬過來就不是了。
名字開頭加一個點只是為了在檔案總管裡不礙眼;真正擋住「有人先佔位」的
是 mkstemp 自己的 O_CREAT|O_EXCL 加隨機字尾。
"""
d = os.path.dirname(os.path.abspath(path)) or '.'
return tempfile.mkstemp(dir=d, prefix='.' + os.path.basename(path) + tag)
def _atomic_copy(src, dst):
"""備份要嘛完整、要嘛不存在 —— 中間狀態不會留在 dst 這個名字上。
⚠️ 本站有些腳本用 pathlib.Path 存路徑,有些用字串。
2026-08-29 第一版寫成 dst + '.part',在 Path 上直接 TypeError,
等於所有備份都失敗 —— 而且「半截備份被擋下來」那個測試照樣是綠的。
是陰性對照(先證明正常流程真的會產生備份)抓到的。
2026-09-05 起連字尾都不接了,改用 mkstemp(見上面那一段)。
"""
# 兩邊都先過 os.fspath:呼叫端可能給字串也可能給 Path。
src, dst = os.fspath(src), os.fspath(dst)
_refuse_symlink(src, '要備份的來源檔')
_refuse_symlink(dst, '備份檔')
# 先寫到一個別人佔不到的隨機暫名,寫完才改名成正式的備份名。
fd, part = _mkstemp_beside(dst, '.part-')
try:
with os.fdopen(fd, 'wb') as out_, open(src, 'rb') as in_:
shutil.copyfileobj(in_, out_)
out_.flush()
os.fsync(out_.fileno()) # 換名之前先讓內容真的落到磁碟
shutil.copystat(src, part) # 權限與時間跟著來源走(copy2 原本就會做)
# os.replace 是原子的;包在 _NoInterrupt 裡是為了不讓 Ctrl-C 落在它中間。
# record=False:備份檔這個名字本來不存在,換上去沒有蓋掉任何東西,
# 不該讓收尾的訊息叫使用者去還原它。
_replace_and_record(part, dst, record=False)
# 連 Ctrl-C(KeyboardInterrupt)與 SystemExit 都要接。使用者按下去的
# 那一刻正是最容易產生半截檔的時候,所以這裡是 BaseException 不是 Exception。
except BaseException:
try:
if os.path.lexists(part):
os.remove(part)
except OSError:
pass
raise
def _atomic_write_bytes(path, data):
"""把 data 寫成 path:要嘛整份換上去,要嘛 path 完全沒被動到。
腳本裡三個「寫回遊戲檔」的地方全部走這裡(改攝影機的散裝 .txt 與封裝 .big、
開關元素的封裝 .big)。跟 _atomic_copy 同一套做法,差別只在來源是記憶體裡
的位元組而不是另一個檔。
⚠️ 2026-09-05 之前這三個地方各自寫 open(路徑 + '.tmp', 'wb'):名字猜得到
(見上面那一段),而且整支腳本沒有一個 fsync —— 原本的說明還特地寫著
「斷電不在保證範圍內」。現在換名之前會先 fsync。
"""
path = os.fspath(path)
_refuse_symlink(path, '要寫入的遊戲檔')
fd, tmp = _mkstemp_beside(path, '.tmp-')
try:
with os.fdopen(fd, 'wb') as f_:
f_.write(data)
f_.flush()
os.fsync(f_.fileno())
if os.path.exists(path):
# mkstemp 開出來的是 0600。直接換上去會讓原本讀得到這個遊戲檔的
# 其他使用者/程式突然讀不到,所以把正本的權限套過來。
shutil.copymode(path, tmp)
# 換名 + 登記走同一個出口(見 _replace_and_record):
# 這兩件事被包成不可中斷的一段,Ctrl-C 落在中間也不會讓收尾說錯話。
_replace_and_record(tmp, path)
except BaseException:
try:
if os.path.lexists(tmp):
os.remove(tmp)
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 量到的為準,這裡不寫會過期的數字)。
⚠️ 只有「宣稱得比實際大」才算被截斷,才拒絕。反過來(宣稱得比實際小)
是打包器沒更新這一欄,備份其實是完整的 —— 一律拒絕會變成
「改得進去、永遠還原不回來」的單向陷阱。那種檔改走它自己的目錄:
每一項的 offset + size 都要落在檔案裡面,截斷的備份一定會超出去
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)
if not os.path.exists(bak):
raise SystemExit('找不到備份:%s' % bak)
n = os.path.getsize(bak)
if n == 0:
raise SystemExit(
'備份是 0 bytes(多半是上次備份到一半被中斷),不敢拿它覆蓋 %s。' % dst)
with open(bak, 'rb') as _f:
head = _f.read(8)
def _stop(why):
raise SystemExit(
'這份備份是壞的,不敢拿它覆蓋 %s。\n'
' %s\n'
' 多半是備份途中被中斷(磁碟滿、外接碟拔掉、按了 Ctrl-C)。\n'
' 請改用你自己另外留的那一份備份。' % (dst, why))
# 第 2 道(封裝檔):檔頭 +0x04 起的 4 個位元組寫著「這個檔應該多大」。
# 這一欄兩種位元組順序都遇得到,所以兩種都算一次,任一種對得上就放行;
# 被截斷的備份兩種都對不上。
if len(head) == 8 and head[:4] == b'BIGF':
le = struct.unpack('<I', head[4:8])[0]
be = struct.unpack('>I', head[4:8])[0]
if le != n and be != n:
# 只有「檔頭宣稱的比實際大」才是被截斷的樣子。
if min(le, be) > n:
_stop('檔頭說它應該是 %d bytes(或 %d),實際只有 %d bytes。'
% (le, be, n))
# 反過來(檔頭那一欄本來就寫得比實際小)不可以判死:那是打包器沒更新
# 這一欄,備份很可能是完整的。寫入端明文允許這種檔(size_field_order
# 兩種都對不上時退回 little-endian),所以這裡若一律拒絕,就會變成
# 「改得進去、永遠還原不回來」的單向陷阱,而訊息還會叫使用者去用
# 別的備份 —— 那一份其實是逐位元組完整的。
# 改用這個格式自己的目錄來驗:每一項的資料都要落在檔案裡面。
# 被截斷的備份,後面的項目一定會指到檔尾外面。
try:
d = open(bak, 'rb').read()
cnt = int.from_bytes(d[8:12], 'big')
if not 0 < cnt < MAX_ENTRIES:
_stop('封裝檔的目錄說有 %d 個項目,讀不出來。' % cnt)
q = 16
for i in range(cnt):
if q + 8 > n:
_stop('封裝檔的目錄在第 %d 項處被截斷。' % (i + 1))
o, sz = struct.unpack('>II', d[q:q + 8])
if o + sz > n:
_stop('封裝檔第 %d 項的資料指到 %d bytes,實際只有 %d bytes。'
% (i + 1, o + sz, n))
q = d.find(b'\x00', q + 8)
if q < 0:
_stop('封裝檔第 %d 項的名稱沒有結束符。' % (i + 1))
q += 1
except SystemExit:
raise
except (struct.error, IndexError, OSError):
_stop('讀不出封裝檔的目錄結構,它壞了。')
return _do_copy(bak, dst)
# 第 3 道(語系檔):檔頭 +16 的 4 個位元組(小端)指向 LOCL 字串區,
# 順著它走到最後一條字串的位移。截斷過的檔那個位移一定會指到檔尾外面。
if len(head) >= 4 and head[:4] == b'LOCH':
try:
d = open(bak, 'rb').read()
L = struct.unpack('<I', d[16:20])[0]
if L + 16 > n or d[L:L + 4] != b'LOCL':
_stop('語系檔的字串區(LOCL)應該在位移 %d,那裡不是 LOCL。' % L)
lcnt = struct.unpack('<I', d[L + 12:L + 16])[0]
if lcnt <= 0 or L + 16 + lcnt * 4 > n:
_stop('語系檔的位移表被截斷了(宣告 %d 條)。' % lcnt)
last = struct.unpack('<I', d[L + 16 + (lcnt - 1) * 4:L + 20 + (lcnt - 1) * 4])[0]
if L + last >= n:
_stop('語系檔最後一條字串在位移 %d,超出檔案結尾(%d bytes)。'
% (L + last, n))
except SystemExit:
raise
except (struct.error, IndexError):
_stop('讀不出語系檔的結構,它壞了。')
# 第 4 道(執行檔):MZ 檔頭 +0x3C 指向 PE 檔頭;PE +6 是節區數、
# +20 是選用檔頭長度。每個節區描述固定 40 個位元組,其中 +16 是原始長度、
# +20 是原始位移。最遠的節區指到哪裡,檔案至少就要有多長。
if len(head) >= 2 and head[:2] == b'MZ':
try:
d = open(bak, 'rb').read()
pe = struct.unpack('<I', d[0x3C:0x40])[0]
if pe + 24 > n or d[pe:pe + 4] != b'PE\x00\x00':
_stop('執行檔的 PE 檔頭不在它該在的地方,檔案不完整。')
nsec = struct.unpack('<H', d[pe + 6:pe + 8])[0]
optsz = struct.unpack('<H', d[pe + 20:pe + 22])[0]
sec = pe + 24 + optsz
end = 0
for i in range(nsec):
o = sec + i * 40
if o + 40 > n:
_stop('執行檔的節區表被截斷了(宣告 %d 個節區)。' % nsec)
raw_sz, raw_off = struct.unpack('<II', d[o + 16:o + 24])
end = max(end, raw_off + raw_sz)
if end > n:
_stop('執行檔的節區指到 %d bytes,實際只有 %d bytes。' % (end, n))
except SystemExit:
raise
except (struct.error, IndexError):
_stop('讀不出執行檔的結構,它壞了。')
# 通用地板 —— 非 BIGF 走到這裡
if os.path.exists(dst):
live = os.path.getsize(dst)
if live > 0 and n * 2 < live:
_stop('備份只有 %d bytes,而要被蓋掉的那個檔有 %d bytes ——'
'差太多了(不到一半)。' % (n, live))
return _do_copy(bak, dst)
def _do_copy(bak, dst):
"""真正動手覆蓋的地方 —— 但它不是「一行 copy2」,是先寫旁邊、驗過才換名。
獨立成一個函式,是為了讓上面每一道把關都只有這一個出口。
以後有人加新的檢查,不會不小心繞過去。
── 2026-09-05 改成原子還原(第二輪唯讀稽核 🔴 第 1 條)──────────────
原本是 shutil.copy2(備份, 遊戲檔)。copy2 會**先把遊戲檔截成 0 bytes**
再一段一段寫回去。上面那五道把關擋得住「備份是壞的」,擋不住
「複製到一半斷掉」(Ctrl-C、磁碟滿、外接碟被拔)—— 那一種壞掉的是正本,
備份還好好的,但使用者手上已經沒有能玩的遊戲檔了。
而還原之後才做的比對只能告訴他「已經壞了」,擋不住它壞。
現在的順序:
1. 在遊戲檔旁邊開一個別人佔不到的隨機暫存檔(mkstemp,不是猜得到的名字)
2. 寫完 flush + fsync,再把正本原本的權限套到暫存檔上
3. 讀回來跟備份比 sha256 —— 不一樣就整個放棄
4. 都對了才 os.replace 換名。換名是原子的:正本要嘛是舊的、要嘛是新的
任何一步失敗都把暫存檔刪掉,正本一個位元組都沒被動到。
"""
bak, dst = os.fspath(bak), os.fspath(dst)
_refuse_symlink(bak, '備份檔')
_refuse_symlink(dst, '要還原的目標檔')
fd, tmp = _mkstemp_beside(dst, '.restore-')
try:
with os.fdopen(fd, 'wb') as out_, open(bak, 'rb') as in_:
shutil.copyfileobj(in_, out_)
out_.flush()
os.fsync(out_.fileno())
if os.path.exists(dst):
shutil.copymode(dst, tmp)
if _sha256(tmp) != _sha256(bak):
raise DataError(
'寫出來的內容跟備份對不起來,已經放棄這次還原 ——\n'
' %s 一個位元組都沒有被動到,你原本的檔案還在。' % dst)
try:
st = os.stat(bak)
os.utime(tmp, (st.st_atime, st.st_mtime))
except OSError:
pass # 時間戳沒設成功不影響內容,不值得放棄還原
# 換名 + 登記走同一個出口(見 _replace_and_record):
# 這兩件事被包成不可中斷的一段,Ctrl-C 落在中間也不會讓收尾說錯話。
_replace_and_record(tmp, dst)
except BaseException:
try:
if os.path.lexists(tmp):
os.remove(tmp)
except OSError:
pass
raise
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except Exception:
pass
# 這兩個上限不是為了效能,是為了擋掉「檔案自己宣稱的數字」:
# 壞掉或被動過手腳的檔可以宣稱要解壓 4 GB、或宣稱目錄有兩億項,
# 照著做就是把記憶體吃光。
MAX_UNCOMPRESSED = 64 * 1024 * 1024
MAX_ENTRIES = 100000
VISIBILITY_FIELD = 1 # 元件名稱之後的第 1 個參數 = 顯示開關
DEFAULT_BIG = os.path.join('data', 'frontend', 'ingame.big')
# 可以考慮動的指令。這張表是第一道關卡,不是最後一道:find_targets 過完
# 這張表,還要再問一次 visibility_of,那一行的第 1 個參數真的讀得到
# 0 或 1 才會動手。兩道都過才寫得下去。
#
# 哪些指令有那一欄,是在本站測試機的 data/frontend/ 上數的:六個封裝檔、
# 332 個版面檔、141,767 行(其中 141,765 行認得出指令,另外 2 行是空行;
# 同一個資料夾裡還有一個跟 frontend.big 位元組完全相同的舊備份,
# 沒有算進去)。
# · 冒號後面只有一格,沒有第 1 個參數:VR 3,376 行、LS 5,057、
# LF 943、END 332,還有 SE 10,316 與 TE 3,154(長相就是 VR:78、
# SE:xbu0、TE:14182;END 那一行冒號後面是空的)。SE 與 TE 留在這張
# 表裡是這張表列錯了,不是它們真的有
# 那一欄;它們過不了 visibility_of 那一關,所以不會壞檔。
# · 有第 1 個參數、那一格卻不是開關:TS 331 行放的是空白,
# SF 1,002 行放的是版面檔檔名(SF:NAVBAR,fes_navbar.fel,…)。
# · 有 0 或 1 卻刻意不放行:KA 355 行、VS 128 行、SC 2,044 行。
# SC 是 2,036 個 1 配 8 個 0,第 4 到第 7 欄 2,044 行全部是
# 0,0,640,480,版面配置跟 TX/SH/RT 同款;但本站沒有獨立證據證明
# 那一格就是顯示開關,所以一律不放行。
# 不在表裡、又不屬於上面幾類的指令,第 1 個參數是別的東西,寫進去就是壞檔。
TOGGLEABLE = {'GR', 'GG', 'SG', 'TX', 'TL', 'TE', 'TB', 'SH', 'RT',
'SL', 'SE', 'BU', 'FC', 'FR'}
class DataError(Exception):
"""檔案不存在或格式不符預期。訊息是給人看的。"""
# ─────────────────────────────────────────────────────────
# QFS / RefPack
# ─────────────────────────────────────────────────────────
def size_field_order(raw):
"""看檔頭那一欄用哪一種位元組順序,回傳 '<'(little)或 '>'(big)。
BIGF 檔頭 +0x04 起的四個位元組是「檔案總大小」。用哪一種順序不是這個格式
天生的性質,是看手上這一份被誰重新打包過。本站用 BIGF 檔頭認過三份 data
資料夾:兩份剛安裝好的原版(英文版 207 個封裝檔、繁體中文版 205 個)全部是
little-endian,連兩份裡最大的封裝檔 models.big(172,992,803 bytes)也是;
big-endian 只出現在本站測試機那份疊過模組的 data 資料夾,384 個封裝檔裡
有 10 個,分別是 models.big、frontend/portrait.big、
audio/spch_pbp/pnamehdr.big、audio/cd/spch_pbp/pnamedat.big,加上
coornite / dodgnite / wrignite 三個球場夜間檔(stadium 與它的原版球場
備份資料夾各一套)。
⚠️ 不可以照檔名或檔案大小猜:同一份測試機裡,檔名一樣的
audio/spch_pa/pnamehdr.big 與 audio/cd/spch_pa/pnamedat.big 反而是
little-endian。寫回去時必須沿用原檔那一種,不然會寫進跟原檔不同的位元組。
兩種都對不上時退回 little-endian —— 那代表這個檔的檔頭本來就不一致,
寫回去之後的複驗會再偵測一次,兩種都對不上就會擋下來。
"""
n = len(raw)
if struct.unpack('<I', raw[4:8])[0] == n:
return '<'
if struct.unpack('>I', raw[4:8])[0] == n:
return '>'
return '<'
def qfs_decompress(data):
"""把 EA 的 QFS(社群又叫 RefPack)解開。不是 QFS 就原樣回傳。
認法是**第 2 個位元組等於 0xFB**,第 1 個位元組是旗標(最常見 0x10)。
接著是「解開之後有多大」,這個數字是 big-endian:
· 旗標最低位為 1 → 位移 6 起算 4 個位元組,本體從第 10 個位元組開始
· 否則 → 位移 2 起算 3 個位元組,本體從第 5 個位元組開始
本體是一連串控制碼。每個控制碼帶 0 到 3 個(或一整段)原樣位元組,
外加一次「往回抄」的指令,那就是壓縮省下來的地方。
每一種控制碼各佔幾個位元組、抄多長多遠,見下面主迴圈各分支的註解。
"""
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 guard():
# ⚠️ 只檢查檔頭宣稱的大小是不夠的 —— 那是「檔案自己說的」。
# 一個惡意檔可以宣稱很小(通過上面那道),再用反向參照無限吐資料,
# 把記憶體吃光。實際輸出也必須有上限,而且上限就是它自己宣稱的大小。
if len(out) > size:
raise DataError('QFS 解出來的資料超過檔頭宣稱的 %d 位元組 —— '
'這個檔可能已損毀或被動過手腳' % size)
def copy_back(offset, length):
"""從已經解出來的資料往回 offset 個位元組,抄 length 個過來。
⚠️ 抄的範圍可以跟自己重疊(offset 小於 length),那不是 bug 是刻意的:
這種編碼用它來表示「同一個樣式連續重複」。所以只能一個一個抄,
不可以整段切片複製,切片會抄到還沒生出來的位元組。
"""
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
guard()
# 主迴圈:一次讀一個控制碼。控制碼的值落在哪個區間,
# 就決定它自己佔幾個位元組、後面帶幾個原樣位元組、要往回抄多長多遠。
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 個位元組,不往回抄。
# (再往上就撞進 0xFC 的區間了,所以 112 是這一族的上限。)
if b0 >= 0xE0:
n = ((b0 & 0x1F) << 2) + 4; pos += 1
out += data[pos:pos + n]; pos += n; continue
# 0xC0-0xDF:控制碼佔 4 個位元組。往回抄 5 到 1,028 個,最遠 131,072。
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:佔 3 個位元組。往回抄 4 到 67 個,最遠 16,384。
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:最短的一種,佔 2 個位元組。往回抄 3 到 10 個,最遠 1,024。
else:
b1 = data[pos + 1]; pos += 2
n = b0 & 0x03
length = ((b0 & 0x1C) >> 2) + 3
offset = ((b0 & 0x60) << 3) + b1 + 1
# 上面三種共用的收尾:先吐 n 個原樣位元組,再做那一次往回抄。
out += data[pos:pos + n]; pos += n
copy_back(offset, length)
# 結束碼那一段可能多吐幾個位元組,以檔頭宣稱的長度為準切掉。
return bytes(out[:size])
def qfs_compress_literal(data):
"""純 literal 編碼:不做字串比對,瞬間完成,格式一樣合法。
代價是檔案略大,但因為我們是「接到檔尾」,大一點沒有影響。
"""
n = len(data)
# 檔頭 5 個位元組:0x10 0xFB 是 QFS 的招牌,後面 3 個是「解開後多大」,
# big-endian。對應 qfs_decompress 那兩條路裡「旗標最低位為 0」的那一條。
out = bytearray([0x10, 0xFB, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF])
# 0xE0 那一族的段長一定是 4 的倍數,所以先把除不盡的 0-3 個位元組留給結束碼。
tail = n % 4
body = n - tail
pos = 0
# 每段最多 112 個位元組,再大就撞進 0xFC(結束碼)的區間。
while pos < body:
chunk = min(112, body - pos)
out.append(0xE0 | ((chunk - 4) // 4))
out += data[pos:pos + chunk]
pos += chunk
# 結束碼順便把剛才留下來的餘數帶完。
out.append(0xFC | tail)
out += data[pos:]
return bytes(out)
# ─────────────────────────────────────────────────────────
# BIG 目錄
# ─────────────────────────────────────────────────────────
def list_entries(data):
"""讀封裝檔的目錄,回傳 [(名稱, TOC 欄位位置, 資料 offset, 資料長度), ...]
BIGF 的檔頭是 16 個位元組:
+0x00 'BIGF' 四個字
+0x04 整個檔案多大(這一欄兩種位元組順序都遇得到,見 size_field_order)
+0x08 目錄有幾項 ← big-endian
+0x0C 目錄區結束位置 ← big-endian
(**不是**第一筆資料的位置。本站拿剛安裝好的原版 207 個封裝檔驗過,
中間常有 1 到 3 個位元組的填充,最多的一個差 119)
接著每一項是「4 位元組位移 + 4 位元組長度」(**兩個都是 big-endian**),
後面接一個以 NUL(0x00)結尾的名字,長度不固定。
第二欄那個「TOC 欄位位置」是這一項的 8 個位元組在檔案裡的位置。
留著它,寫回去時就只要改那 8 個位元組,不必重寫整個目錄。
而「不重寫整個目錄」正是這支腳本不會弄丟孤兒資料的原因。
"""
if len(data) < 16 or data[:4] != b'BIGF':
raise DataError('檔頭前四碼不是 BIGF,這不是 EA 封裝檔')
count = int.from_bytes(data[8:12], 'big')
if not 0 < count < MAX_ENTRIES:
raise DataError('目錄項目數異常(%d),檔案可能已損毀' % count)
items = []
pos = 16 # 目錄緊接在 16 個位元組的檔頭後面
for i in range(count):
field = pos
# 每一步都先確認還讀得到。被截斷的檔要在這裡停下來講人話,
# 而不是丟一個使用者看不懂的 struct.error。
if pos + 8 > len(data):
raise DataError('目錄在第 %d 項處被截斷' % (i + 1))
offset, size = struct.unpack('>II', data[pos:pos + 8])
pos += 8
# 名字沒有長度欄位,只能一路找到 NUL 為止。
end = data.find(b'\x00', pos)
if end < 0:
raise DataError('第 %d 項的名稱沒有結束符' % (i + 1))
if offset + size > len(data):
raise DataError('第 %d 項的資料範圍超出檔案結尾' % (i + 1))
items.append((data[pos:end].decode('latin-1', 'replace'), field, offset, size))
pos = end + 1
return items
# ─────────────────────────────────────────────────────────
# FEL 解析(只做我們需要的:指令 / 名稱 / 顯示開關)
# ─────────────────────────────────────────────────────────
def fel_line_count(text):
"""算行數。
版面檔的換行是 CRLF,直接 count('\\r\\n')+1 會把結尾那個空字串多算一行。
但「結尾有沒有換行符」不可以寫死:本站測試機那份 ingame.big(裝過模組)
是 47 個版面檔,其中 46 個以 CRLF 結尾,例外是 fes_hudleft.fel;剛安裝好的原版
是 44 個版面檔,44 個全部以 CRLF 結尾(本站三份未改動的安裝量到的都一樣)。
所以下面用「拆完之後最後一段是不是空字串」動態判斷,兩種都算得對。
"""
parts = text.split('\r\n')
if parts and parts[-1] == '':
parts.pop()
return len(parts)
def fel_lines(text):
"""把版面檔逐行拆開,產出 (行號從1起, 原始行, 縮排, 指令, 名稱, 參數list)。
版面檔一行長這樣:
TX:名稱,顯示開關,保留,X,Y,寬,高,…,字型.ffn,字串編號,對齊,R,G,B,…
↑指令 ↑冒號後面用逗號分隔的參數,第 0 個是名稱
縮排代表階層(群組 GR 底下的元素會多縮幾格),所以縮排要留著給 --where 用。
看不出指令的行(空行、沒有冒號的行)照樣產出,只是後三欄是 None。
這樣行號才不會跳號,而行號正是使用者拿去對照的東西。
"""
for i, raw in enumerate(text.split('\r\n')):
stripped = raw.lstrip(' ')
indent = len(raw) - len(stripped)
if not stripped or ':' not in stripped:
yield i + 1, raw, indent, None, None, None
continue
cmd, _, rest = stripped.partition(':')
parts = rest.split(',')
name = parts[0] if parts else ''
yield i + 1, raw, indent, cmd, name, parts
def visibility_of(parts):
"""這一行的顯示開關是什麼:'1' 開、'0' 關、None 代表這一行沒有這一欄。
只認 '0' 與 '1' 兩個值,其他東西(空白、別的數字)一律當成「沒有開關」。
寧可少認一行,也不要把不確定的欄位當成開關去寫。寫錯就是壞檔。
"""
if parts is None or len(parts) <= VISIBILITY_FIELD:
return None
v = parts[VISIBILITY_FIELD].strip()
return v if v in ('0', '1') else None
# ─────────────────────────────────────────────────────────
# .LOC 字串表:把版面檔裡的「字串編號」換成真正的文字
#
# 結構(實測 IGENG.LOC / FEENG.LOC 皆同):
# LOCH 20 bytes 檔頭
# LOCI 索引:每 4 bytes = (LOCL 索引 << 16) | 字串編號
# LOCL 文字:count + 位移表 + UTF-16LE 內容
# ─────────────────────────────────────────────────────────
def load_loc(path):
"""回傳 {字串編號: 文字}。讀不到就回空 dict,不中斷主流程。"""
try:
d = open(path, 'rb').read()
except OSError:
return {}
if len(d) < 32 or d[:4] != b'LOCH':
return {}
try:
# ⚠️ 這個檔全部是 little-endian,跟 BIGF 目錄(big-endian)剛好相反。
# 兩種順序在同一支腳本裡並存,讀錯一個就整片是亂數。
# LOCI 索引區:+24 是這一區多長、+28 是有幾條,條目從 +32 開始。
loci_size = struct.unpack('<I', d[24:28])[0]
icnt = struct.unpack('<I', d[28:32])[0]
# 每條 4 個位元組:高 16 位是「第幾條文字」,低 16 位是「字串編號」。
# 版面檔上寫的是字串編號,所以非得靠這張表才轉得到文字。
ents = [struct.unpack('<I', d[32 + i * 4:36 + i * 4])[0] for i in range(icnt)]
# LOCL 文字區緊接在 20 個位元組的檔頭 + 整個 LOCI 區之後。
L = 20 + loci_size
if d[L:L + 4] != b'LOCL':
return {}
lsize = struct.unpack('<I', d[L + 4:L + 8])[0]
lcnt = struct.unpack('<I', d[L + 12:L + 16])[0]
# 位移表:每條 4 個位元組,而且是**相對於 LOCL 這個位置**,
# 不是相對於檔案開頭;少加這個 L 就會讀到別的地方去。
offs = [struct.unpack('<I', d[L + 16 + i * 4:L + 20 + i * 4])[0] for i in range(lcnt)]
def text(i):
"""第 i 條文字。
沒有長度欄位,所以用「下一條的起點」當這一條的終點;
最後一條沒有下一條,拿整個 LOCL 區的長度收尾。
內容是 UTF-16LE(一個字兩個位元組),尾巴補的 NUL 要去掉。
"""
a = L + offs[i]
b = L + offs[i + 1] if i + 1 < lcnt else L + lsize
return d[a:b].decode('utf-16-le', 'replace').rstrip('\x00')
out = {}
for x in ents:
idx, sid = x >> 16, x & 0xFFFF
if idx < lcnt:
out[sid] = text(idx)
return out
except (struct.error, IndexError):
return {}
# 字串表的檔名跟語言版本綁在一起。
# ⚠️ EA 官方繁體中文版**不是**把中文塞進 ENG 檔,而是另外放一對 JPN 檔
# (FEJPN.LOC / IGJPN.LOC),ENG 那一對根本不存在。
# 這支工具原本只找 ENG,所以在繁中版上會回傳 0 筆 —— 而且不報錯,
# 只是後面每一個字串都顯示不出來。那正是本站最怕的靜默失敗。
LOC_NAMES = ('FEENG.LOC', 'IGENG.LOC', # 英文版與大多數社群中文化模組
'FEJPN.LOC', 'IGJPN.LOC') # EA 官方繁體中文版
def load_all_loc(gamedir):
"""比賽中與前端兩張表都讀。比賽中的優先。
四個候選檔名都試,有幾個讀到幾個。回傳空的代表一個都沒找到。
"""
out = {}
for name in LOC_NAMES:
p = os.path.join(gamedir, 'data', name)
if os.path.isfile(p):
out.update(load_loc(p))
return out
def loc_files_present(gamedir):
"""實際找到哪幾個字串表 —— 讀不到的時候要能告訴使用者查過哪些。"""
return [n for n in LOC_NAMES
if os.path.isfile(os.path.join(gamedir, 'data', n))]
def string_id_of(parts):
"""TX 這類指令的參數裡,字型檔後面那個數字就是字串編號。"""
if not parts:
return None
for i, c in enumerate(parts):
if c.strip().lower().endswith('.ffn') and i + 1 < len(parts):
v = parts[i + 1].strip()
if v.isdigit():
return int(v)
return None
def cmd_strings(texts, strings, fn=None):
"""把版面檔裡的字串編號換成真正的文字印出來(唯讀)。
為什麼要有它:版面檔上寫的是「字型.ffn,1234」這種編號,
光看版面檔不知道那一格在遊戲裡顯示什麼字。有這個模式,不用開遊戲
就認得出「哦,這個元素就是畫面上那行字」。
讀不到字串表時**不會靜默跳過**,那正是本站最怕的失敗方式。
所以這裡把查過哪四個檔名、為什麼有四個,全部印出來給使用者看。
"""
if not strings:
print('\n讀不到字串表。')
print(' 本工具會找這四個檔:%s' % '、'.join(LOC_NAMES))
print(' (前兩個是英文版與多數社群中文化模組用的;')
print(' 後兩個是 EA 官方繁體中文版用的 —— 它把中文放在日文的槽位。)')
print(' 請確認你的遊戲資料夾底下的 data\\ 裡至少有其中一個。')
return
print('\n字串表共 %d 條' % len(strings))
files = [fn] if fn else sorted(texts)
if fn and fn not in texts:
raise DataError('封裝檔裡沒有 %s。先用 --list 看有哪些。' % fn)
rows = []
for f in files:
for ln, raw, ind, cmd, name, parts in fel_lines(texts[f]):
sid = string_id_of(parts)
if sid is None:
continue
rows.append((f, ln, name, sid, strings.get(sid)))
known = [r for r in rows if r[4] is not None]
print(' 這些版面檔裡有 %d 處引用字串,其中 %d 處查得到文字\n' % (len(rows), len(known)))
print(' %-30s %6s %-20s %6s %s' % ('版面檔', '行號', '元素', '編號', '文字'))
print(' ' + '-' * 88)
seen = set()
for f, ln, name, sid, txt in rows[:300]:
if txt is None:
txt = '(表裡沒有這個編號)'
key = (f, sid)
if key in seen:
continue
seen.add(key)
print(' %-30s %6d %-20s %6d %s' % (f[:30], ln, (name or '')[:20], sid, txt[:40]))
if len(rows) > 300:
print(' …只顯示前 300 筆,用 --strings <版面檔> 縮小範圍')
# ─────────────────────────────────────────────────────────
# 各種動作
# ─────────────────────────────────────────────────────────
def load_big(path):
"""把整個封裝檔一次讀進記憶體。
ingame.big 這種檔是幾 MB 等級,一次讀完最單純;後面所有解析都在
記憶體裡做,不會邊讀邊改遊戲檔。
"""
try:
return open(path, 'rb').read()
except OSError as e:
raise DataError('讀不到 %s:%s' % (path, e))
def fel_texts(raw, items):
"""把封裝檔裡每一個版面檔解壓成文字,回傳 {檔名: 文字}。
用 latin-1 解碼是刻意的:latin-1 把 0 到 255 每一個位元組一對一映射成
一個字元,所以「解碼再編碼回去」保證拿回一模一樣的位元組。
版面檔實際上是 ASCII,但用 latin-1 才不會在遇到意外位元組時炸掉或走樣。
解不開的項目直接跳過不中斷:一個壞掉的項目不該讓整個封裝檔都看不了。
"""
out = {}
for n, field, o, s in items:
if not n.lower().endswith('.fel'):
continue
try:
out[n] = qfs_decompress(raw[o:o + s]).decode('latin-1')
except DataError:
pass
return out
def _geom(parts):
"""版面檔一行的第 4-7 個欄位是 X, Y, 寬, 高。回傳 None 代表這行沒有座標。"""
if parts is None or len(parts) < 7:
return None
try:
g = [float(parts[i]) for i in (3, 4, 5, 6)]
except ValueError:
return None
return g
def cmd_where(texts, strings, fn):
"""列出一個版面檔裡「顯示中」的元素在畫面上的位置。
座標是 640x480 這個固定畫面的絕對座標,不是相對於上層群組。
實測 ingame.big 全部 18,649(這台被模組疊過的測試機 47 個版面檔的母體;原版是 44 個) 個有座標的元素,用四種「落在畫面內」的
判定條件各算一次,絕對解讀都是相對解讀的兩倍以上:
整個框都在畫面內 96.4% 對 18.9%
左上角在畫面內 98.5% 對 25.2%
框與畫面有重疊 99.4% 對 25.7%
中心點在畫面內 99.0% 對 23.6%
(2026-08-29 訂正:這四對原本寫 49.1 / 91.0 / 88.5 / 83.2,
是整欄左移一格、讀成「保留欄, X, Y, 寬」算出來的。
錨點:SC: 那一行用第 4-7 欄讀出來剛好是 0,0,640,480 = 畫面本身;
用第 3-6 欄讀寬度會變成 0。只有一種讀法成立。)
不管怎麼定義,絕對解讀都贏。
"""
if fn not in texts:
raise DataError('封裝檔裡沒有 %s。先用 --list 看有哪些。' % fn)
print('\n%s 裡顯示中的元素(畫面是 640x480)\n' % fn)
print(' 行號 X Y 寬 x 高 指令:名稱 文字 / 所屬群組')
print(' ' + '-' * 76)
# stack 記的是「現在在哪幾層群組裡面」。版面檔沒有結束標記,階層完全靠縮排,
# 所以縮排一回到同層或更淺,就把上面那幾層彈掉。印出來的「所屬群組」靠它。
stack, n = [], 0
for ln, raw, ind, cmd, name, parts in fel_lines(texts[fn]):
if not cmd:
continue
while stack and stack[-1][0] >= ind:
stack.pop()
if cmd in ('GR', 'GG', 'SG'):
stack.append((ind, name))
# 只看得見的三種:文字、貼圖、色塊。群組本身不佔畫面,不列。
if cmd not in ('TX', 'SH', 'RT'):
continue
if visibility_of(parts) != '1':
continue
g = _geom(parts)
if g is None or g[2] <= 0 or g[3] <= 0:
continue
sid = string_id_of(parts)
txt = strings.get(sid, '') if sid else ''
tail = ('「%s」 ' % txt) if txt else ''
tail += '< ' + '/'.join(x for _, x in stack)
print(' %5d %4g %4g %4gx%-4g %-22s %s'
% (ln, g[0], g[1], g[2], g[3], cmd + ':' + name, tail))
n += 1
print('\n 共 %d 個。全程唯讀。' % n)
def cmd_bigs(gamedir):
"""掃 data/frontend 底下每一個封裝檔,看哪些裝著版面檔。
有些檔案叫 .big 但根本不是 EA 封裝檔(實測 feonlyln.big 的檔頭是
SHPI,那是圖片容器不是封裝檔),所以這裡讀不出來就照實說,不當錯誤。
"""
import glob
folder = os.path.join(gamedir, 'data', 'frontend')
paths = sorted(glob.glob(os.path.join(folder, '*.big')))
if not paths:
raise DataError('%s 裡沒有 .big。第一個參數是不是給錯資料夾了?' % folder)
print('\n掃 %s\n' % folder)
print(' 封裝檔 大小 項目 版面檔 行數')
print(' ' + '-' * 66)
# 三類分開報,不混為一談:
# hit 裝著版面檔的
# other 是封裝檔,但裡面一個版面檔都沒有(圖片、球衣、球場那些)
# bad 根本讀不出來的(例如檔頭是 SHPI 的那種,它不是封裝檔)
hit, other, bad = [], 0, []
for path in paths:
base = os.path.basename(path)
try:
raw = load_big(path)
items = list_entries(raw)
texts = fel_texts(raw, items)
except (DataError, OSError) as e:
bad.append((base, str(e)))
continue
if not texts:
other += 1
continue
lines = sum(fel_line_count(t) for t in texts.values())
hit.append((base, os.path.getsize(path), len(items), len(texts), lines))
for b, size, n, nf, lines in sorted(hit, key=lambda r: -r[4]):
print(' %-26s %7.1f MB %6d %7d %9s' % (b, size / 1048576.0, n, nf, format(lines, ',')))
print(' ' + '-' * 66)
print(' %-26s %10s %6d %7d %9s'
% ('合計 %d 個封裝檔' % len(hit), '',
sum(r[2] for r in hit), sum(r[3] for r in hit),
format(sum(r[4] for r in hit), ',')))
print('\n 另外 %d 個封裝檔裡沒有版面檔(圖片、球衣、球場那些)。' % other)
for b, msg in bad:
print(' ⚠ %s 讀不出來:%s' % (b, msg))
print('\n 想看某一個:--big data/frontend/<檔名> --list')
def sniff(blob):
"""回傳這個項目「是什麼」。看內容,不看檔名。
為什麼不看檔名:封裝檔裡的名字是打包的人取的,被模組疊過的封裝檔裡
什麼名字都可能出現。開頭那幾個位元組才是真的:
第 2 個位元組是 0xFB 代表 QFS 壓縮,解開之後再看一次裡面是什麼
(常見的是又一層 BIGF,或純文字)。
"""
if len(blob) < 4:
return '太短'
if len(blob) > 1 and blob[1] == 0xFB: # QFS/RefPack
try:
u = qfs_decompress(blob)
except DataError as e:
return 'QFS(解不開:%s)' % e
inner = u[:4].decode('latin-1', 'replace')
if u[:4] == b'BIGF':
try:
n = len(list_entries(u))
return 'QFS → BIGF(裡面又有 %d 個項目)' % n
except DataError:
return 'QFS → BIGF(目錄讀不出來)'
if all(32 <= c < 127 or c in (9, 10, 13) for c in u[:64]):
return 'QFS → 純文字'
return 'QFS → %s' % inner
return blob[:4].decode('latin-1', 'replace')
def cmd_entries(raw, items):
"""列出封裝檔裡的每一個項目 —— 不管是不是版面檔。
跟 --list 的差別:--list 只看版面檔並解析行數,
這個是「先看看裡面到底有什麼」,對圖片、模型、球場那些封裝檔才有用。"""
print('\n封裝檔內共 %d 個項目:\n' % len(items))
print(' %-36s %12s %s' % ('名稱', '大小', '內容是什麼(看檔頭認的)'))
print(' ' + '-' * 76)
# 一邊印一邊統計種類:項目多的封裝檔逐項看沒有意義,
# 「裡面九成是 QFS 壓縮的貼圖」這種結論才有用。
kinds = {}
for n, f, o, s in items:
k = sniff(raw[o:o + s])
kinds[k] = kinds.get(k, 0) + 1
print(' %-36s %12s %s' % (n[:36], format(s, ','), k))
print(' ' + '-' * 76)
print(' 合計 %s bytes' % format(sum(i[3] for i in items), ','))
if len(kinds) > 1 or len(items) > 6:
print('\n 種類統計:')
for k, v in sorted(kinds.items(), key=lambda x: -x[1]):
print(' %-46s %d 個' % (k, v))
paths = [n for n, _, _, _ in items if '/' in n or '\\' in n]
if paths:
print('\n ⚠ 有 %d 個項目的名稱帶資料夾路徑(這在 MVP 的封裝檔裡很少見)。' % len(paths))
print()
# ─────────────────────────────────────────────────────────
# 總設定檔 datafile.txt —— 攝影機
#
# 格式:每行 0x<識別碼> <欄位數> <名字或表頭> ;編號 值;編號 值…
# 「欄位數」那個數字不是型別編號,是這一行有幾個欄位(21,548 行零例外)。
#
# ⚠️ 欄位名稱表是**另外一種列**(第三個欄位也是數字,不是 <名字>),
# 不是攝影機那一列自己帶的。攝影機由**兩張**表管:116 欄那張管打擊、
# 128 欄那張管守備;而哪一張表管哪幾台是**看位置**決定的,
# 往前最近的那一張就是它適用的表,不是拿欄位數去配。
# 本站量了 9 份有攝影機記錄的總設定檔(6 份封裝版 datafile.big、
# 3 份散裝版 datafile.txt),9 份全部都是 123 列表頭、其中 99 列帶欄位名、
# 39 台攝影機;116 欄那張管打擊 32 台,128 欄那張管守備 6 台加 HeadViewer。
# 拿欄位數去配一定會錯:剛安裝好的原版英文版 datafile.big 裡
# 有 4 台攝影機自己的那一列欄位數也是 116,而它們都不是名稱表。
# ⚠️ 表頭在第幾行、每個欄位是幾號,兩者都跟著版本跑,見 camera_table 的說明。
#
# ⚠️ 2026-08-28 訂正。這裡原本寫「編號在攝影機家族之間共用 —— 拿它去讀
# 其他 38 筆,X/Y/Z/FOV/Pitch/Heading/Roll 全是數字…9 項全中」。
# **那句話只對 Batting 那 32 台成立。**
# Fielding 6 台 + HeadViewer 記的是相對位移,跟打擊視角不是同一套編號。
# 拿打擊表的座標欄去讀守備視角,讀到的那一格裝的是
# 「OffsetX#OffsetY#OffsetZ」這種標籤字串,不是數字;
# 拿打擊表的 FOV 那一格去讀,讀到的是另一個欄位,不可能是視野角度。
#
# ⚠️ 2026-09-03 再訂正:上面原本舉的欄位編號跟著版本跑,已經拿掉。
# 本站量了 9 份有攝影機記錄的總設定檔:6 份是**封裝版**
# (遊戲裡有 datafile.big,剛安裝好的原版英文版就是這種),
# 3 份是**散裝版**(沒有 datafile.big,datafile.txt 直接躺在資料夾裡,
# 官方中文版就是這種)。行號與編號**組內完全一致,兩組之間整個重排過**:
#
# FieldingView1 的這一格 封裝版 散裝版
# OffsetX / OffsetY / OffsetZ ;19 ;20 ;21 ;13 ;14 ;15
# 緊接在後面那兩格的標籤字串 ;22 ;23 ;16 ;17
# ;53 這一格叫什麼 Pitch VerticalFraming
# 真正的 FOV 是幾號 ;65 ;46
# 守備名稱表在第幾行 8516 9216
# FieldingView1 在第幾行 8519 9219
#
# 打擊名稱表兩組都在第 7940 行,打擊表的 ;53 兩組也都叫 FOV。
# ⚠️ 這 9 份裡封裝的 6 份全是英文版基底、散裝的 3 份全是中文版基底,
# 所以「是封裝散裝造成的、還是英文中文造成的」,用這批檔分不開。
# 能確定的只有一條:不要記編號,讓程式去讀表頭,這支工具就是這樣做的。
#
# 後果是真的:訂正之前 --set X=5 會把那個字串換成 5,把記錄寫壞,
# 而工具自己的複驗還會說「讀回來的內容與預期相同 ✅」——
# 因為它只檢查「寫進去的跟打算寫的一樣」,不檢查「這欄該不該寫」。
# 現在改成:**要寫的那一格,現在的值必須本來就是數字**,否則拒絕。
#
# 教訓跟本站在球場那一課踩的是同一個:拿最漂亮的樣本(Batting 32 台
# 全中)推出通則,而反例就在同一份檔案裡。
# ─────────────────────────────────────────────────────────
DATAFILE_ENTRY = 'datafile.txt'
CAM_RE = re.compile(r'^0x([0-9a-f]+) (\d+)<([^>]*)>(.*)$')
HDR_RE = re.compile(r'^0x([0-9a-f]+) (\d+) (\d+)(.*)$')
# 只放行「一望即知是數字」的欄位。其他欄位(對焦目標、標記點名稱、
# 各種開關)改錯的後果不好預測,這支工具不碰。
# 2026-08-28 加 OffsetX/Y/Z:守備視角沒有絕對的 X/Y/Z,它記的是相對位移,
# 那三欄在守備表裡是純數字(FieldingView1 = 0.00 / 3.00 / 0.00)。
# 名字在自己那張表裡找,所以打擊視角不會多出這三個選項。
CAM_SETTABLE = ('X', 'Y', 'Z', 'FOV', 'Pitch', 'Heading', 'Roll',
'OffsetX', 'OffsetY', 'OffsetZ')
# 「一般的十進位數字」長什麼樣。刻意**不**收 nan / inf / infinity,也不收
# Python 才認得的底線寫法(1_0)—— 那幾種 float() 全都收得下,遊戲卻讀不懂。
PLAIN_NUMBER_RE = re.compile(r'^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$')
# 散裝 datafile.txt 的一行長什麼樣:0x<識別碼> <欄位數> 之後才是內容。
# 還原之前拿它驗備份的最後一行完不完整。
DATAFILE_RECORD_RE = re.compile(rb'^0x[0-9a-f]+ \d+')
_INF = float('inf')
def _is_number(v):
"""這一格是不是數字。
這不是在做輸入檢查,是**護欄**:同一個欄位編號在打擊視角是座標,
在守備視角可能是一段結構字串。本來不是數字的格子一律不准寫。
"""
try:
float(v)
return True
except (TypeError, ValueError):
return False
def _pairs(rest):
"""把「;編號 值;編號 值…」拆成 [(編號, 值), …]。
值本身可以有空白(例如 OffsetX#OffsetY#OffsetZ 那種標籤),
所以一路吃到下一個分號為止,不能拿空白當分隔。
"""
return [(int(a), b) for a, b in re.findall(r';(\d+) ([^;]*)', rest)]
def loose_datafile(path):
"""這個路徑是不是散裝的 datafile.txt(不是封裝檔)。
⚠️ 2026-08-28 加。官方中文版**沒有 datafile.big** ——
它把整個 data/datafile/ 散裝成 466 個 .txt 直接放在資料夾裡,
datafile.txt 就躺在那裡。data/anims/ 也一樣(730 個散裝檔,沒有 anims.big)。
本站在此之前所有量測都是拿英文版做的,所以這一課對裝中文版的人
是從頭到尾跑不起來的。這個判斷式就是為了那件事。
"""
return os.path.isfile(path) and os.path.basename(path).lower() == DATAFILE_ENTRY
def datafile_text(raw, items, loose_path=None):
"""把 datafile.txt 解出來成一整份文字。散裝的直接讀,封裝的從裡面取出來。
封裝檔裡那一份**不一定壓縮過**(第 2 個位元組是 0xFB 才是),所以先看再決定。
一樣用 latin-1 解碼,理由跟 fel_texts 相同:一個位元組換一個字元,
寫回去的時候拿得回原本的位元組。
"""
if loose_path:
with open(loose_path, 'rb') as _f:
return _f.read().decode('latin-1')
hit = [x for x in items if x[0] == DATAFILE_ENTRY]
if not hit:
raise DataError('這個封裝檔裡沒有 %s。\n'
' 英文版的攝影機在 data/datafile/datafile.big;\n'
' 官方中文版沒有那個封裝檔,請改指 data/datafile/datafile.txt。'
% DATAFILE_ENTRY)
_, _, off, size = hit[0]
blob = raw[off:off + size]
if len(blob) > 1 and blob[1] == 0xFB:
blob = qfs_decompress(blob)
return blob.decode('latin-1')
def camera_table(text):
"""回傳 (名稱→(行號, 欄位數, {編號:值}, 這一台適用的欄位名表), 主表)。
⚠️ 2026-08-28 重寫。原本的做法是「找欄位數 116 那一列當名稱表,
套用到全部 39 台攝影機」。**那是錯的,而且會寫壞檔案。**
這個檔裡有**不只一張**攝影機名稱表,而**一張表管的是它後面那一段,
到下一張表為止**:
116 欄那張 → 後面 32 台 BattingView*
128 欄那張 → 後面 7 台 FieldingView1-6 + HeadViewer
⚠️ 表頭在第幾行、每個欄位是幾號,兩者都跟著版本跑,所以這裡不寫死。
本站量的 9 份有攝影機記錄的總設定檔分成兩組,組內完全一致:
6 份封裝版(有 datafile.big)守備名稱表在第 8516 行、
FieldingView1 在第 8519 行;3 份散裝版(只有 datafile.txt)
分別在第 9216 行與第 9219 行。打擊名稱表兩組都在第 7940 行。
兩張表的編號完全不同。同一個 ;13,打擊表在封裝版叫 LocMarkerName、
在散裝版叫 LocationObject;守備表在封裝版叫 RelativeObject、
在散裝版才叫 OffsetX。;53 打擊表兩組都叫 FOV,守備表在封裝版叫
Pitch、在散裝版叫 VerticalFraming。
怎麼確定不是巧合(錨點,不是數量吻合):守備表說某三格是
OffsetX/OffsetY/OffsetZ,而剛安裝好的原版裡 FieldingView1 那三格
正好是 0.00/3.00/0.00,緊接在後面的兩格就寫著字串
「OffsetX#OffsetY#OffsetZ」(封裝版 ;22/;23,散裝版 ;16/;17),
**標籤字串跟同一張表的欄位名對得起來**,而且跟打擊視角
(X/Y/Z 三格之後緊接著寫「X#Y#Z」的格子)是同一個結構。
用「往前找最近的表頭」這條規則跑一次:32 台全落在打擊表、
7 台全落在守備表,**零例外、零模稜兩可**。
"""
cams = {}
cur = None # 目前生效的欄位名稱表 = 往前最近的那一張
for i, line in enumerate(text.split('\n')):
# 表頭列(第三個欄位是數字)= 一張新的欄位名稱表。從這一行往下,
# 攝影機的編號就要照這張表解讀,直到下一張表出現為止。
m = HDR_RE.match(line)
if m:
t = {n: v.strip() for n, v in _pairs(m.group(4)) if v.strip()}
if t:
cur = t
continue
# 資料列(第三個欄位是 <名字>)。只挑攝影機。
# 這個檔有兩萬多行,絕大多數跟視角無關。
m = CAM_RE.match(line)
if m and re.search(r'(Batting|Fielding)View|HeadViewer', m.group(3)):
if not cur:
raise DataError('第 %d 行的攝影機「%s」前面找不到任何欄位名稱表。'
% (i + 1, m.group(3)))
cams[m.group(3)] = (i, int(m.group(2)),
{n: v.strip() for n, v in _pairs(m.group(4))},
cur)
if not cams:
raise DataError('這個檔裡找不到任何攝影機。')
# 主表 = 最多台攝影機共用的那一張(給還在用舊回傳值的地方)
main = max((c[3] for c in cams.values()),
key=lambda t: sum(1 for c in cams.values() if c[3] is t))
return cams, main
def cmd_cameras(text):
"""把總設定檔裡的攝影機列成一張表(唯讀)。
每一台用**它自己那一張**欄位名稱表去讀,不是全部套同一張。
這是 2026-08-28 訂正的重點,原因見 camera_table 的說明。
讀出來不是數字的格子標成「(不是數字)」,那是在提醒使用者
那一格是結構欄位而不是座標,別拿它當 X/Y 去改。
"""
cams, names = camera_table(text)
print('\n這個檔裡有 %d 個攝影機:\n' % len(cams))
print(' %-28s %9s %9s %9s %8s %8s %9s'
% ('名稱', 'X', 'Y', 'Z', 'FOV', 'Pitch', 'Heading'))
print(' ' + '-' * 86)
fams = {}
for nm in sorted(cams):
_, _, f, nt = cams[nm]
fams.setdefault(id(nt), []).append(nm)
# 每一台用**它自己那一張**名稱表,不是全部套同一張。
ix = {v: k for k, v in nt.items()}
def g(k, _f=f, _ix=ix):
n = _ix.get(k)
v = _f.get(n, '') if n is not None else ''
return v if (v == '' or _is_number(v)) else '(不是數字)'
print(' %-28s %9s %9s %9s %8s %8s %9s'
% (nm, g('X'), g('Y'), g('Z'), g('FOV'), g('Pitch'), g('Heading')))
if len(fams) > 1:
print()
print(' ⚠️ 這 %d 台攝影機不是共用同一張欄位名稱表(這個檔裡有 %d 張)。'
% (len(cams), len(fams)))
print(' 守備視角記的是相對位移,沒有絕對的 X/Y ——')
print(' 上面標「(不是數字)」的格子是結構欄位,不是座標。')
print('\n 想看某一個的全部欄位:--camera <名稱>')
print(' 想改:--camera <名稱> --set FOV=30,Y=60 (沒加 --apply 只是預覽)')
def cmd_camera_show(text, name):
"""列出某一台攝影機的全部欄位(唯讀)。
每一欄後面會標「←可改」或「←這一欄不是數字,不能改」。
兩個條件要同時成立才標可改:名稱表說它在白名單裡,**而且**
這一格現在真的是數字。只滿足前者就寫下去,會把結構欄位換成座標。
"""
cams, names = camera_table(text)
if name not in cams:
near = [c for c in cams if name.lower() in c.lower()]
raise DataError('找不到攝影機「%s」。%s' % (
name, ('你是不是要找:' + '、'.join(sorted(near)[:6])) if near
else '先用 --cameras 看有哪些。'))
ln, cnt, f, names = cams[name]
print('\n%s —— 第 %d 行,共 %d 個欄位\n' % (name, ln + 1, cnt))
print(' %5s %-30s %s' % ('編號', '欄位名', '值'))
print(' ' + '-' * 60)
for n in sorted(f):
v = f[n]
if v == '':
continue
# 只有「名稱表說可改」而且「現在真的是數字」才標可改,見上方訂正。
star = (' ←可改' if names.get(n) in CAM_SETTABLE and _is_number(v)
else (' ←這一欄不是數字,不能改' if names.get(n) in CAM_SETTABLE else ''))
print(' %5d %-30s %s%s' % (n, names.get(n, '(這個編號沒有名字)'), v, star))
ok = sorted(v for k, v in names.items()
if v in CAM_SETTABLE and _is_number(f.get(k, '')))
print('\n 這一台可改的欄位:%s' % ('、'.join(ok) or '(沒有)'))
def _apply_sets(text, name, sets):
"""算出「改完之後的整份文字」,回傳 (新文字, [(欄位名, 舊值, 新值)])。**不寫檔。**
預覽跟真的寫入走的是同一條路,預覽只是不把結果存下來,
所以「你看到的預覽」跟「真的會寫進去的東西」不可能不一致。
"""
cams, _main = camera_table(text)
if name not in cams:
raise DataError('找不到攝影機「%s」。先用 --cameras 看有哪些。' % name)
ln, cnt, f, names = cams[name] # ← 這一台自己的名稱表,不是全域那一張
idx = {v: k for k, v in names.items()}
lines = text.split('\n')
line = lines[ln]
changes = []
for key, val in sets:
if key not in CAM_SETTABLE:
here = sorted(v for k, v in names.items()
if v in CAM_SETTABLE and _is_number(f.get(k, '')))
raise DataError('欄位「%s」不在可改清單裡。\n'
' 這一台(%s)可以改的是:%s'
% (key, name, '、'.join(here) or '(沒有)'))
try:
fval = float(val)
except ValueError:
raise DataError('「%s」的值要是數字,你給的是「%s」' % (key, val))
# ⚠️ float() 收得下的東西比遊戲讀得懂的多:nan、inf、infinity,
# 還有 Python 才認得的底線寫法(1_0),以及大到溢位成 inf 的 1e400。
# 那幾種寫進總設定檔就是遊戲讀不懂的壞值,而且下面逐台逐欄的複驗
# 照樣全過 —— 它比對的是「寫進去的跟打算寫的一樣」,
# 不是「這個值遊戲讀不讀得懂」。所以這裡再過一道:
# 字面上要是一般的十進位數字,數值也要是有限的。
if not PLAIN_NUMBER_RE.match(val) or fval != fval or fval in (_INF, -_INF):
raise DataError('「%s」的值要是一般的十進位數字(例如 30、25.3、-1.5),'
'不能是 nan / inf 這一類寫法,你給的是「%s」' % (key, val))
# ⚠️ 2026-08-28:每台攝影機用自己那一張名稱表,所以「這個名字在
# 這一台存不存在」要先問。原本直接 idx[key] 會丟 KeyError ——
# 對守備視角下 --set X=5 就會噴 traceback 而不是講人話。
if key not in idx:
avail = sorted(v for k, v in names.items()
if v in CAM_SETTABLE and _is_number(f.get(k, '')))
raise DataError(
'%s 沒有「%s」這一欄。\n'
' 這個檔裡有不只一張欄位名稱表,守備視角(FieldingView1-6)與\n'
' HeadViewer 用的跟打擊視角不是同一張 —— 它們記的是相對位移\n'
' (OffsetX / OffsetY / OffsetZ),沒有絕對的 X/Y/Z。\n'
' 這一台可以改的是:%s\n'
' 想看全部欄位:--camera %s'
% (name, key, '、'.join(avail) or '(沒有可改的數字欄位)', name))
n = idx[key]
if n not in f:
raise DataError('%s 這個攝影機沒有 %s 這一欄(它只有 %d 個欄位)'
% (name, key, cnt))
old = f[n]
# ⚠️ 2026-08-28 加的護欄,見檔案上方的訂正說明。
# 名稱表是照 Batting 家族編的;守備視角同一個編號裝的是
# 「OffsetX#OffsetY#OffsetZ」這種結構字串。原本會把它換成數字,
# 等於拿掉遊戲要用的欄位。規則很簡單:**本來不是數字的格子不准寫**。
if old.strip() and not _is_number(old):
raise DataError(
'%s 的「%s」現在裝的是「%s」,不是數字 —— 不動它。\n'
' 欄位名稱表是照打擊視角編的,守備視角(FieldingView1-6)與\n'
' HeadViewer 用的是另一套編號,同一個編號在那裡是別的東西。\n'
' 這一格改下去會把遊戲要用的欄位拿掉,所以這支工具拒絕。'
% (name, key, old))
# 只換這一格,前後一個字元都不動。
# 用正則鎖住「;編號 」這個開頭再吃到下一個分號為止 ——
# 早一版用字串比對(而且還把值 re.escape 過)找不到目標,
# 那是把正則跟字串兩套 API 混用的典型錯誤。
cell = re.compile(r'(;%d )([^;]*)' % n)
hits = cell.findall(line)
if len(hits) != 1:
raise DataError('第 %d 行裡「;%d 」出現 %d 次(應該剛好一次),中止'
% (ln + 1, n, len(hits)))
if hits[0][1] != old:
raise DataError('第 %d 行第 %d 欄現在是「%s」,跟先前讀到的「%s」不一致,中止'
% (ln + 1, n, hits[0][1], old))
line = cell.sub(lambda m: m.group(1) + val, line, count=1)
changes.append((key, old, val))
f[n] = val
lines[ln] = line
new_text = '\n'.join(lines)
if len(new_text.split('\n')) != len(text.split('\n')):
raise DataError('行數改變了,中止')
return new_text, changes
def cmd_camera_set(bigpath, raw, items, text, name, sets, apply_it, loose_path=None):
"""改攝影機欄位。沒加 --apply 就只印預覽,一個位元組都不寫。
兩條路:散裝的 datafile.txt 直接整份覆寫(官方中文版走這條);
封裝在 datafile.big 裡的走 append:接到檔尾 + 只改目錄與檔頭。
兩條路寫完都會重讀複驗,但強度不一樣,說明的時候不要混講:
· 封裝版:重讀 → 重新解出文字 → 逐台逐欄比對前後的攝影機表,
證明「**只有**指定的那幾欄變了」,最後印「✅ 複驗通過」。
· 散裝版:只把檔案讀回來跟算好的內容整份比對。它抓得到寫壞、寫半截、
編碼跑掉,但沒有回頭比「算好的內容跟原本的檔案只差那幾欄」。
它印的是「複驗:讀回來的內容與預期相同 ✅」,不會印「✅ 複驗通過」。
只驗「寫進去的跟打算寫的一樣」抓不到「一開始就算錯欄位」那一類錯。
散裝版目前就停在這個強度,而官方中文版走的正是這一條。
"""
new_text, changes = _apply_sets(text, name, sets)
print('\n【預覽】%s\n' % name)
print(' %-12s %12s → %s' % ('欄位', '現在', '改成'))
print(' ' + '-' * 44)
for k, o, v in changes:
print(' %-12s %12s → %s' % (k, o or '(空)', v))
d = len(new_text) - len(text)
print('\n 文字長度變化:%+d 位元組 · 行數不變' % d)
if not apply_it:
print('\n 以上只是預覽,沒有改到任何檔案。')
print(' 確定要改的話,在同一行指令最後加上 --apply')
return
target = loose_path or bigpath
backup = target + '.datafilebak'
# 動手之前先確認這兩個名字都是「真的檔案」而不是符號連結。
# 連結指到資料夾外面的話,寫過去就是在使用者沒說要動的地方動手。
_refuse_symlink(target, '要修改的遊戲檔')
_refuse_symlink(backup, '備份檔')
if not os.path.exists(backup):
_atomic_copy(target, backup)
print('\n 已備份 → %s' % os.path.basename(backup))
else:
print('\n 備份已存在,保留最早那一份 → %s' % os.path.basename(backup))
if loose_path:
# 散裝版(官方中文版):直接改那個 .txt,沒有封裝檔要維護。
_atomic_write_bytes(loose_path, new_text.encode('latin-1'))
print(' 已寫入 %s(散裝版,沒有封裝檔要更新)' % os.path.basename(loose_path))
with open(loose_path, 'rb') as f_:
back = f_.read().decode('latin-1')
if back != new_text:
raise DataError('讀回來的內容跟預期不符,請立刻 --restore-datafile')
# ⚠️ 這一句只證明「寫回去的檔案跟算好的內容一樣」,沒有逐台逐欄比對。
# 逐欄那一段在下面封裝版才有,所以這條路刻意不印「✅ 複驗通過」,
# 免得讀者以為兩條路一樣嚴。頁面上的成功標準也照這個分開寫。
print(' 複驗:讀回來的內容與預期相同 ✅')
return
# ── 封裝版:新資料接到檔尾,舊資料一個位元組都不動 ──────────────
# 為什麼不重新打包:這種封裝檔裡常有目錄指不到的孤兒資料(社群模組疊出來的),
# 「讀出全部再打包回去」會把它們整批丟掉。
blob = qfs_compress_literal(new_text.encode('latin-1'))
entry = next(x for x in items if x[0] == DATAFILE_ENTRY)
_, field, _, _ = entry
out = bytearray(raw)
new_off = len(out)
out += blob
# 目錄那 8 個位元組:新位移 + 新長度,固定 big-endian。
struct.pack_into('>II', out, field, new_off, len(blob))
# 檔頭 +0x04 的「檔案總大小」:這一欄兩種位元組順序都遇得到,
# 所以先量原檔用的是哪一種,再照那一種寫回去。
order = size_field_order(raw)
struct.pack_into(order + 'I', out, 4, len(out))
# 先寫暫存檔、fsync,再改名換上:腳本中途被打斷(按 Ctrl-C、磁碟滿、
# 外接碟被拔)留下來的是那個暫存檔,原本的封裝檔完好。
# 暫存檔的名字是隨機的,別人先佔不到(見 _atomic_write_bytes)。
_atomic_write_bytes(bigpath, out)
print(' 已寫入:新資料接在第 %d 個位元組,只改了目錄 8 bytes + 檔頭 4 bytes' % new_off)
print(' ⚠ 這個檔會變大 —— 本工具用的是「不做壓縮的合法編碼」,')
print(' 解出來一樣正確,但體積接近解壓後的大小。')
# ── 複驗 ──
raw2 = load_big(bigpath)
actual = os.path.getsize(bigpath)
order_after = size_field_order(raw2)
declared = struct.unpack(order_after + 'I', raw2[4:8])[0]
if declared != actual:
raise DataError('檔頭寫的大小 %d 跟實際 %d 對不上,請立刻 --restore-datafile'
% (declared, actual))
items2 = list_entries(raw2)
if len(items2) != len(items):
raise DataError('寫入後項目數變了,請立刻 --restore-datafile')
t2 = datafile_text(raw2, items2)
if t2 != new_text:
raise DataError('讀回來的內容跟預期不符,請立刻 --restore-datafile')
# 逐欄確認:只有指定的那幾欄變了,其他一個都沒動
before, _ = camera_table(text)
after, _ = camera_table(t2)
if set(before) != set(after):
raise DataError('寫入後攝影機數量變了,請立刻 --restore-datafile')
moved = []
for nm in before:
if before[nm][2] != after[nm][2]:
moved.append(nm)
if moved != [name]:
raise DataError('動到的攝影機不是只有 %s,而是 %s,請立刻 --restore-datafile'
% (name, moved))
diff = {k for k in before[name][2]
if before[name][2][k] != after[name][2].get(k)}
_, names_after = camera_table(t2)
want = {k for k, v in names_after.items() if v in dict((c[0], 1) for c in changes)}
print(' 複驗:%d 個項目全部讀得回 · 攝影機 %d 個 · 只有「%s」被改動'
% (len(items2), len(after), name))
print(' 複驗:這個攝影機有 %d 個欄位被改,預期 %d 個'
% (len(diff), len(changes)))
if len(diff) != len(changes):
raise DataError('改動的欄位數不對,請立刻 --restore-datafile')
print(' 複驗:檔頭寫的大小 %d = 實際檔案大小 ✅' % declared)
print(' ✅ 複驗通過')
def cmd_restore_datafile(bigpath, loose_path=None):
"""把總設定檔還原成本工具做的那一份備份。
只認自己的 .datafilebak,不會去碰別課留下的備份。
覆蓋之前先看備份的開頭像不像該有的樣子(封裝版看 BIGF,
散裝版看它是不是以數字或 0x 開頭的那種文字檔),
真正的長度把關在 _restore_from_backup 裡。
⚠️ 散裝版(官方中文版走的那一條)只看開頭是不夠的:開頭對、後面被截掉的
備份,_restore_from_backup 只剩「不得小於一半」那道通用地板擋著 ——
截掉 40% 照樣過,遊戲的總設定檔當場被砍半,而且還印
「複驗:內容與備份相同 ✅」。
這個格式有東西可以驗:它是一行一筆記錄、以換行收尾的文字檔
(本站量的 4 份 —— 3 份散裝版加 1 份剛安裝好的原版封裝版解出來的 ——
都是 21,548 行、都以換行結束,最後一行都是完整的一筆記錄)。
所以再加兩道:備份的最後一個位元組要是換行,最後一行要是一筆完整的記錄。
截斷幾乎一定停在半行,這兩道就會擋下來。
"""
target = loose_path or bigpath
backup = target + '.datafilebak'
if not os.path.exists(backup):
raise DataError('找不到備份 %s。這支工具只還原它自己做的備份。'
% os.path.basename(backup))
with open(backup, 'rb') as _f:
head = _f.read(4)
if loose_path:
# 散裝版的備份是純文字,不會有 BIGF 檔頭。檢查它像不像 datafile。
if not head[:2].isdigit() and not head.startswith(b'0x'):
raise DataError('備份檔看起來不像 datafile.txt,不敢拿它覆蓋。')
# 尾巴也要驗(見上面的說明):完整的 datafile.txt 以換行收尾,
# 最後一行是一筆完整的記錄。截斷的備份停在半行,這兩道過不了。
# 只讀最後 64 KiB 就夠:本站量到最長的一行是 8,263 bytes,這個窗是它的八倍。
WINDOW = 65536
bak_size = os.path.getsize(backup)
with open(backup, 'rb') as _f:
_f.seek(max(0, bak_size - WINDOW))
tail = _f.read()
if not tail.endswith(b'\n'):
raise DataError('這份備份的結尾不是換行 —— 它多半是備份到一半被中斷的\n'
' 半截檔(完整的 %s 每一行都以換行結束)。不敢拿它覆蓋。'
% DATAFILE_ENTRY)
body = tail[:-1]
# 窗裡看不到上一個換行(= 最後一行比整個窗還長)時,看不出這一行從哪裡開始,
# 就不做這道判斷 —— 寧可少擋一次,也不要把好的備份誤判成壞的。
if b'\n' in body or bak_size <= WINDOW:
last = body.rsplit(b'\n', 1)[-1]
if not DATAFILE_RECORD_RE.match(last):
raise DataError('這份備份的最後一行不是一筆完整的記錄,它壞了。'
'不敢拿它覆蓋。')
elif head != b'BIGF':
raise DataError('備份檔開頭不是 BIGF,不敢拿它覆蓋。')
_restore_from_backup(backup, target)
same = open(backup, 'rb').read() == open(target, 'rb').read()
print('\n 已還原 ← %s' % os.path.basename(backup))
print(' 複驗:內容與備份%s' % ('相同 ✅' if same else '不同 ❌'))
if not same:
raise DataError('還原後內容跟備份不一樣,請手動檢查。')
def cmd_list(raw, items):
"""列出封裝檔裡的版面檔:壓縮後大小、解壓後大小、行數、畫面數(唯讀)。
這是沒給任何模式時的預設動作。使用者第一次跑,先讓他看到
「這個檔裡有什麼」,再從裡面挑一個往下查。
"""
fels = [(n, o, s) for n, f, o, s in items if n.lower().endswith('.fel')]
print('\n封裝檔內共 %d 個項目,其中 %d 個版面檔:\n' % (len(items), len(fels)))
print(' %-34s %9s %9s %7s %7s' % ('檔名', '壓縮後', '解壓後', '行數', '畫面'))
print(' ' + '-' * 70)
tot_lines = 0
for n, o, s in sorted(fels):
try:
t = qfs_decompress(raw[o:o + s]).decode('latin-1')
except DataError:
print(' %-34s %9d %9s' % (n, s, '(解不開)')); continue
lines = fel_line_count(t)
screens = len(re.findall(r'^SC:', t, re.M))
tot_lines += lines
print(' %-34s %9d %9d %7d %7d' % (n, s, len(t), lines, screens))
print(' ' + '-' * 70)
print(' 合計 %d 行\n' % tot_lines)
def cmd_find(texts, pattern, cmd_filter=None):
"""掃過全部版面檔,找名字含這個關鍵字的元素(唯讀)。
這是整支工具的入口動作:你只知道畫面上有個東西叫 SPEED,
但不知道它在哪一個檔的第幾行。比對一律轉大寫,因為版面檔裡的
名稱習慣是全大寫,而使用者不會想管大小寫。
最後印的「分佈」是刻意的:同一個名字散在好幾個檔裡時,
你要先知道該改哪一個。
"""
pat = pattern.upper()
hits = []
for fn in sorted(texts):
for ln, raw, ind, cmd, name, parts in fel_lines(texts[fn]):
if not cmd or not name:
continue
if cmd_filter and cmd != cmd_filter.upper():
continue
if pat not in name.upper():
continue
hits.append((fn, ln, cmd, name, visibility_of(parts), raw.strip()))
if not hits:
print('\n找不到名字含「%s」的元素。' % pattern)
print('提示:名稱通常是全大寫英文,例如 SPEED、RATING、SCORE。')
return
print('\n找到 %d 個元素:\n' % len(hits))
print(' %-32s %6s %-4s %-22s %s' % ('版面檔', '行號', '指令', '名稱', '顯示'))
print(' ' + '-' * 78)
for fn, ln, cmd, name, vis, _ in hits[:200]:
mark = {'1': '開', '0': '關'}.get(vis, '—')
print(' %-32s %6d %-4s %-22s %s' % (fn, ln, cmd, name[:22], mark))
if len(hits) > 200:
print(' …另外還有 %d 個' % (len(hits) - 200))
byfile = collections.Counter(h[0] for h in hits)
print('\n 分佈:', ', '.join('%s×%d' % (f.replace('fes_', '').replace('.fel', ''), c)
for f, c in byfile.most_common(6)))
def cmd_show(texts, fn, screen=None, limit=80):
"""把某個版面檔印出來(唯讀),行首標行號,關掉的行標一個 ✕。
--screen 可以只看一個畫面:從那個 SC 開始,到下一個 SC 或 END 為止。
不指定畫面時預設只印前 80 行。有些版面檔上千行,整份倒到終端機
對讀者沒有幫助。
"""
if fn not in texts:
raise DataError('封裝檔裡沒有 %s。先用 --list 看有哪些。' % fn)
t = texts[fn]
lines = list(fel_lines(t))
if screen:
want = screen.upper()
start = end = None
for ln, raw, ind, cmd, name, parts in lines:
if cmd == 'SC' and name.upper() == want:
start = ln
elif start and cmd in ('SC', 'END') and ln > start:
end = ln; break
if start is None:
names = [n for _, _, _, c, n, _ in lines if c == 'SC']
raise DataError('%s 裡沒有畫面「%s」。有的是:%s'
% (fn, screen, ', '.join(names[:12])))
end = end or len(lines)
lines = [l for l in lines if start <= l[0] < end]
print('\n%s / 畫面 %s(第 %d–%d 行)\n' % (fn, screen, start, end - 1))
else:
# 行數要跟 --list 報的是同一個數字:fel_lines 會把結尾換行造成的那個
# 空字串也產出來,fel_line_count 不會 —— 不對齊的話同一個版面檔
# 在兩個模式報的行數會差 1(而且 --show 會多印一行空的),
# 而頁面到處拿 --list 的行數當對照。
total = fel_line_count(t)
lines = lines[:total]
print('\n%s(共 %d 行,顯示前 %d 行)\n' % (fn, total, min(limit, total)))
lines = lines[:limit]
for ln, raw, ind, cmd, name, parts in lines:
vis = visibility_of(parts)
mark = {'1': ' ', '0': '✕'}.get(vis, ' ')
print(' %5d %s %s' % (ln, mark, raw[:96]))
print('\n ✕ = 這一行的顯示開關是 0(關閉)')
def cmd_extract(texts, fn, outpath):
"""把整個版面檔存成純文字檔,方便用編輯器慢慢看(不動遊戲檔)。
輸出檔已經存在就拒絕,不覆蓋使用者的東西。
⚠️ 存出來的是 CRLF 換行,而且**必須維持 CRLF**。
編輯器統一成 LF 會讓檔案少掉「行數」個位元組,那個檔就壞了。
"""
if fn not in texts:
raise DataError('封裝檔裡沒有 %s。先用 --list 看有哪些。' % fn)
# ⚠️ 這裡不可以用 os.path.exists:它對「指向不存在目標的符號連結」回傳 False,
# 接著 open(...,'wb') 就會順著那個連結,把連結指向的檔案截斷。
# lexists 看的是連結本身,連結在就算「已經存在」。
if os.path.lexists(outpath):
raise DataError('%s 已經存在,換一個檔名以免覆蓋你的東西。' % outpath)
# 輸出路徑是使用者自己給的,可能指到不存在的資料夾或沒有權限的地方。
# 那時候要講人話,不要丟一個使用者看不懂的 traceback ——
# 跟這支腳本在別處的標準一致。
# O_CREAT|O_EXCL:「不存在才建立」由作業系統一次做完,上面那道檢查跟這裡
# 之間就算被人塞了一個符號連結進來,也會在這裡失敗而不是跟著它走。
try:
fd = os.open(outpath, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
with os.fdopen(fd, 'wb') as f:
f.write(texts[fn].encode('latin-1'))
except OSError as e:
raise DataError('寫不出 %s:%s\n'
' (輸出檔要放在已經存在、而且有寫入權限的資料夾裡)'
% (outpath, e))
print('\n 已存成 %s(%d bytes)' % (outpath, len(texts[fn])))
print(' 這是唯讀動作,沒有改到遊戲檔案。')
print(' ⚠ 用編輯器打開時注意:這個檔是 CRLF 換行,存檔不要改成 LF。')
def find_targets(text, name, cmd_filter=None):
"""在一個版面檔裡找出「叫這個名字、而且真的有顯示開關」的每一行。
四道篩選缺一不可:認得出指令、指令屬於 TOGGLEABLE、
(指定了 --cmd 的話)指令要對得上、而且這一行的第 1 個參數真的是 0 或 1。
最後那一道是關鍵:沒有開關欄的行如果被改,寫進去的就是別的欄位。
同名的會全部回傳,不是只回第一個:同一個元素常常在四個壘包各有一份,
只改一份的話其他情境還是會冒出來。
"""
want = name.upper()
out = []
for ln, raw, ind, cmd, nm, parts in fel_lines(text):
if not cmd or nm is None:
continue
if cmd not in TOGGLEABLE:
continue
if cmd_filter and cmd != cmd_filter.upper():
continue
if nm.upper() != want:
continue
if visibility_of(parts) is None:
continue
out.append((ln, raw, cmd, parts))
return out
def set_visibility(raw_line, parts, value):
"""把第 1 個參數換成 value,其餘原封不動。
刻意用「拆開再接回去」而不是正則取代:元素名稱裡可能出現任何字元,
這一行後面還有座標與顏色,亂比對會改到別的地方。
連縮排都照原樣接回去。縮排在版面檔裡是有意義的,它代表階層。
"""
stripped = raw_line.lstrip(' ')
indent = raw_line[:len(raw_line) - len(stripped)]
cmd, _, rest = stripped.partition(':')
cells = rest.split(',')
cells[VISIBILITY_FIELD] = value
return indent + cmd + ':' + ','.join(cells)
def cmd_toggle(bigpath, raw, items, texts, fn, name, state, cmd_filter, apply_it):
"""開關某個元素。沒加 --apply 就只印預覽,一個位元組都不寫。
流程:找出目標行 → 印預覽(已經是那個狀態的會說「不動」)→
產生新的版面檔文字 → 備份 → 接到檔尾 → 重讀複驗。
已經是目標狀態的行會被跳過,所以重複跑同一道指令不會累積改動。
複驗任何一項對不上就丟例外,並要使用者立刻 --restore。
"""
if fn not in texts:
raise DataError('封裝檔裡沒有 %s。先用 --list 看有哪些。' % fn)
value = '1' if state == 'on' else '0'
targets = find_targets(texts[fn], name, cmd_filter)
if not targets:
raise DataError('%s 裡找不到可開關的元素「%s」。\n'
' 先用 --find %s 看它在哪個檔、叫什麼。' % (fn, name, name))
print('\n【預覽】%s 裡的「%s」' % (fn, name))
print(' 找到 %d 行:\n' % len(targets))
changed = 0
for ln, line, cmd, parts in targets:
cur = visibility_of(parts)
if cur == value:
print(' %5d %-4s 已經是「%s」,不動' % (ln, cmd, '開' if value == '1' else '關'))
else:
changed += 1
print(' %5d %-4s %s → %s' % (ln, cmd,
'開' if cur == '1' else '關',
'開' if value == '1' else '關'))
if changed == 0:
print('\n 全部已經是這個狀態,不需要改動。')
return
print('\n 會改動 %d 行。' % changed)
if not apply_it:
print('\n 以上只是預覽,沒有改到任何檔案。')
print(' 確定要改的話,在同一行指令最後加上 --apply')
return
# ── 產生新的 fel 文字 ──
old_text = texts[fn]
lines = old_text.split('\r\n')
for ln, line, cmd, parts in targets:
if visibility_of(parts) == value:
continue
lines[ln - 1] = set_visibility(line, parts, value)
new_text = '\r\n'.join(lines)
if len(new_text.split('\r\n')) != len(old_text.split('\r\n')):
raise DataError('行數改變了,中止')
# ── 備份 ──
backup = bigpath + '.feltoolbak'
# 這兩個名字都必須是真的檔案。是符號連結就停 —— 理由同 cmd_camera_set。
_refuse_symlink(bigpath, '要修改的遊戲檔')
_refuse_symlink(backup, '備份檔')
if not os.path.exists(backup):
_atomic_copy(bigpath, backup)
print('\n 已備份 → %s' % os.path.basename(backup))
else:
print('\n 備份已存在,保留最早那一份 → %s' % os.path.basename(backup))
# ── 接到檔尾 + 只改目錄 ──
blob = qfs_compress_literal(new_text.encode('latin-1'))
entry = next(x for x in items if x[0] == fn)
_, field, _, _ = entry
out = bytearray(raw)
new_off = len(out)
out += blob
struct.pack_into('>II', out, field, new_off, len(blob))
# ⚠️ 檔頭第 5-8 個位元組是「整個檔案多大」,它跟緊接著的項目數/目錄長度
# (固定 big-endian)不一樣 —— 這一欄兩種順序都遇得到。
# 實測這台機器上 295 個 BIGF 檔:288 個 little-endian、7 個 big-endian
# (models.big / portrait.big / pnamedat.big / pnamehdr.big 與三個球場夜間檔)。
# 所以不能寫死,要先看原檔用哪一種,就照那一種寫回去。
order = size_field_order(raw)
struct.pack_into(order + 'I', out, 4, len(out))
_atomic_write_bytes(bigpath, out)
print(' 已寫入:新資料接在第 %d 個位元組,只改了目錄 8 bytes + 檔頭 4 bytes' % new_off)
# ── 複驗 ──
raw2 = load_big(bigpath)
# 檔頭那一欄要跟實際檔案大小相等。
# 這條檢查是後來補的:本工具曾經把這一欄寫成 big-endian,
# 檔案內容全對、所有項目都讀得回來、複驗照樣印「通過」——
# 因為讀取端根本不看這一欄。寫入端改壞的東西,要由讀取端以外的檢查抓。
#
# ⚠️ 但補上去的第一版是一句恆真式:寫入時用 order 寫,複驗時
# 用同一個 order 讀 —— 不論 order 選對選錯都一定相等,
# 它抓不到註解裡說要抓的那個錯。
# 改成「重新偵測一次」:size_field_order 是拿兩種順序去比對
# 實際檔案大小的,所以它偵測到的順序才是這個檔真正寫成什麼樣。
actual = os.path.getsize(bigpath)
order_after = size_field_order(raw2)
declared = struct.unpack(order_after + 'I', raw2[4:8])[0]
if declared != actual:
raise DataError('檔頭寫的大小 %d 跟實際 %d 對不上(兩種位元組順序都對不上),'
'請立刻 --restore' % (declared, actual))
if order_after != order:
raise DataError('檔頭大小欄寫成了 %s-endian,原檔是 %s-endian,請立刻 --restore'
% ('big' if order_after == '>' else 'little',
'big' if order == '>' else 'little'))
items2 = list_entries(raw2)
if len(items2) != len(items):
raise DataError('寫入後項目數變了,請立刻 --restore')
t2 = fel_texts(raw2, items2)
if fn not in t2:
raise DataError('寫入後讀不回 %s,請立刻 --restore' % fn)
if t2[fn] != new_text:
raise DataError('寫回的內容與預期不符,請立刻 --restore')
still = [ln for ln, _, _, p in find_targets(t2[fn], name, cmd_filter)
if visibility_of(p) != value]
print(' 複驗:%d 個項目全部讀得回 · 目標元素 %d 個未套用'
% (len(items2), len(still)))
if still:
raise DataError('有 %d 行沒套用成功,請立刻 --restore' % len(still))
print(' 複驗:檔頭寫的大小 %d = 實際檔案大小 ✅' % declared)
print(' ✅ 複驗通過')
def cmd_restore(bigpath):
"""把封裝檔還原成第一次 --apply 之前的樣子。
只認自己建立的 .feltoolbak。覆蓋之前兩邊都要先確認開頭是 BIGF:
拖錯檔案時要停下來,不能無聲蓋掉別的東西然後回報成功。
"""
backup = bigpath + '.feltoolbak'
if not os.path.exists(backup):
raise DataError('找不到備份 %s。這支腳本只在第一次 --apply 時建立備份。'
% os.path.basename(backup))
# ⚠️ 覆蓋之前先確認兩邊都真的是封裝檔。
# 2026-08-25 上線前稽核抓到:原本這裡完全不驗格式,
# 拖錯檔就會拿舊快照無聲蓋掉別的東西,而且回報成功。
with open(backup, 'rb') as _f:
if _f.read(4) != b'BIGF':
raise DataError('這個備份不是本工具建立的(開頭不是 BIGF):%s'
% os.path.basename(backup))
with open(bigpath, 'rb') as _f:
if _f.read(4) != b'BIGF':
raise DataError(
'要還原的目標開頭不是 BIGF:%s\n'
' 兩種可能,先分清楚是哪一種:\n'
' (1) 路徑指錯了 —— 這時候不該還原,先確認 --big 指到哪個檔;\n'
' (2) 這個遊戲檔本身已經壞到連檔頭都不是 BIGF 了。\n'
' 這支工具分不出是哪一種,所以停在這裡。\n'
' 確定是 (2) 的話:把壞掉的那個檔改名留著,\n'
' 再把 %s 複製成原本的檔名 —— 還原本來就只是這一步。'
% (os.path.basename(bigpath), os.path.basename(backup)))
_restore_from_backup(backup, bigpath)
same = open(backup, 'rb').read() == open(bigpath, 'rb').read()
print('\n 已從備份還原 → %s' % os.path.basename(bigpath))
print(' 位元組完全一致:%s' % ('✅ 是' if same else '❌ 否'))
# ⚠️ 印一個 ❌ 然後正常結束(結束碼 0)是不行的:呼叫它的批次檔、
# 或照著頁面一步一步做的人,看的是結束碼。不一致就是失敗。
# (走到這裡幾乎不可能:_do_copy 換名之前就比過 sha256 了。
# 真的走到,代表換名之後又有別的東西動了這個檔。)
if not same:
raise DataError('還原後的內容跟備份不一樣,請手動檢查 —— '
'備份 %s 還在原地,沒有被動過。'
% os.path.basename(backup))
# ─────────────────────────────────────────────────────────
# 自我測試(--selftest):不需要遊戲資料夾,也不碰任何遊戲檔。
# 全部在一個丟得掉的暫存資料夾裡做。
#
# 守門要下餌 —— 只驗「正常流程會過」的測試,分不出
# 「擋住了」跟「這段程式根本沒跑到」。所以擋東西的餌旁邊都配了陰性對照
# (同一份假資料,正常的指令要照常跑完)。
# ⚠️ 2026-09-24 量過一次:把自我測試以外、條件成立就讓它停下來的 64 道守門
# 逐一拆掉(那一個 if 改成永遠不成立)再跑 --selftest,會變紅的只有 2 道。
# 2026-09-26 補了餌(見 _selftest_reader_baits)之後,用同一個量法再量:
# 64 道裡拆掉會紅的是 64 道,而且紅在那一道自己的餌上(逐一看過輸出)。
# ⚠️ 還是不能讀成「每一道守門都有餌」。那個量法只數「if 的本體直接 raise、
# sys.exit 或 return 非 0」的守門,數不到下面幾種(2026-09-26 各自另外量過):
# · _restore_from_backup 裡呼叫 _stop(...) 停下來的 12 道。同樣逐一拆掉再跑,
# 會紅的是 6 道(封裝檔那一段與最後的「不到一半」);另外 6 道(語系檔 LOCH、
# 執行檔 MZ 那兩段)拆掉照樣全綠 —— 這支工具的兩個還原指令都先要求備份開頭是
# BIGF 或像 datafile.txt,走不進那兩段,所以沒有下餌。
# · try/except 的寫法:接到錯誤之後丟出新的錯誤(raise …)、sys.exit、呼叫 _stop,
# 或是直接 return 的 —— return 什麼都算,例如字串表讀不到就回一個空的、
# 座標欄不是數字就回 None、--entries 回一句「解不開」的說明。共 15 道
# (把接的錯誤換成永遠不會發生的那一種再跑):會紅的是 12 道 —— --set 的值不是數字、
# 改攝影機時那一格本來就不是數字的護欄、--extract 的輸出資料夾不存在、封裝檔讀不到、
# 字串表讀不到或被截斷(兩道)、版面檔某一行的座標欄不是數字、--entries 遇到壓縮資料
# 解不開與內層封裝檔的目錄讀不出來(兩道),以及檔尾 __main__ 的三道(錯誤印成一句
# 「❌ …」、沒有權限寫入、Ctrl-C 的收尾)。字串表那兩道與座標那一道是第 28 組的餌,
# 補之前拆掉照樣全綠。拆掉照樣全綠的 3 道:
# 兩道在上面說的語系檔、執行檔那兩段;一道在封裝檔那一段,前面的邊界檢查已經先擋掉,
# 推論要備份在那一段讀的途中被別的程式改掉才會走到(沒有實測),沒有下餌。
# 接到錯誤之後只是略過、印一行或記下來,再接著往下做的(例如 --list 把解不開的項目
# 標成「解不開」),以及先收拾(刪暫存檔、改登記)再把同一個錯誤原樣丟出去、
# 或讓 _stop 的結束直接穿過去的,不算在這 15 道裡。
# · 本身不停下來、只決定要不要做某件事的那種,量法數不到。挑了讀者會踩到的幾道
# 另外補餌,各自單獨拆掉都會紅:「備份已經有了就不再備份」(開關、改攝影機各一道,
# 第 16 組每條路連改兩次)、開關與改攝影機動手之前的四道符號連結檢查(第 25 組)、
# Ctrl-C 收尾時「已經換過了」與「沒動到」兩段(第 27 組)。這一類其餘的沒有逐一量。
# · Ctrl-C 收尾裡「中斷的那一刻正在替換」那一段沒有餌:換名跟登記被 _NoInterrupt
# 包在一起,正常情況走不到那裡(見那一段的註解)。
# · 沒有讀取權限(封裝檔兩個、字串表一個)、資料夾不能寫(一個)那 4 個餌,在 Windows、
# 系統管理員身分、或不記權限的檔案系統上會跳過,那一組後面照實印「跳過 N 個」和原因。
# ─────────────────────────────────────────────────────────
def selftest():
"""回傳 0 = 全過。任何一條不成立就丟 AssertionError,結束碼非 0。
⚠️ 不可以在 python3 **-O** 底下跑:那個旗標會把 assert 整段拿掉,
底下每一個餌的判斷句就一條都不執行,螢幕上照樣一路印「通過」——
那是一片**假的綠燈**,比沒有測試更危險。所以第一件事就是把它擋掉。
"""
if _optimize_level():
print('--selftest 不能在 python3 -O 底下跑:-O 會把 assert 整段拿掉,'
'底下那些餌一個都不會執行,你會看到一片假的綠燈。'
'請拿掉 -O 再跑一次。')
return 2
# 這一行要緊接在守門後面:_optimize_bait() 的陰性對照靠它收工(見那個函式)。
_opt = _optimize_bait(selftest)
passed = []
def ok(what):
passed.append(what)
# ── 零、最上面那道 -O 守門自己的餌(2026-09-24 加)──────────────────
# 結果是開頭那一行 _optimize_bait() 量好的,這裡只記帳。
assert _opt[0], '假裝開了 -O(把 _optimize_level 換成回傳 1),自我測試竟然沒有拒跑'
# 陰性對照:換回原本那一支之後,沒開 -O 就要放行
assert _opt[1], '陰性對照:沒開 -O 守門也擋,或是換回原本那一支沒換成功'
ok('-O 守門:假裝開了 -O 就拒跑,換回來之後照樣放行')
# ── 一、純邏輯:壓縮解壓來回、顯示開關的認法 ──────────────────
# 樣本照版面檔的真實長相寫:指令冒號、逗號分隔、CRLF 換行。
sample = ('SC:HUD,1,0,0,0,640,480\r\n'
' TX:SPEED,1,0,10,20,30,40\r\n'
' RT:BAR,0,0,1,2,3,4\r\n'
' VR:78\r\n').encode('latin-1')
assert qfs_decompress(qfs_compress_literal(sample)) == sample, 'QFS 來回對不上'
# 反向餌:不是 QFS 的資料要原樣回傳,不可以硬解
assert qfs_decompress(b'hello world') == b'hello world', '不是 QFS 卻被動到了'
ok('QFS 來回 + 非 QFS 原樣回傳')
text = sample.decode('latin-1')
rows = {nm: parts for _l, _r, _i, _c, nm, parts in fel_lines(text) if nm}
assert visibility_of(rows['SPEED']) == '1', 'TX 的顯示開關讀錯'
assert visibility_of(rows['BAR']) == '0', 'RT 的顯示開關讀錯'
# 反向餌:VR 冒號後面只有一格,沒有第 1 個參數,不可以硬讀成開關
assert visibility_of(rows['78']) is None, 'VR 沒有開關欄卻讀出了值'
ok('顯示開關:正反兩面 + 沒有那一欄的行')
# 反向餌:find_targets 兩道關卡。SC 那一行第 1 個參數也是 1,
# 但 SC 不在 TOGGLEABLE 裡,不可以被找出來。
assert len(find_targets(text, 'SPEED')) == 1, 'TX 目標找不到'
assert find_targets(text, 'HUD') == [], 'SC 不該被當成可開關的目標'
ok('目標篩選:可開關的找得到、不可開關的擋得住')
# set_visibility 只可以動那一格,別的欄位一個字都不能變
line = ' TX:SPEED,1,0,10,20,30,40'
newline = set_visibility(line, line.lstrip(' ').partition(':')[2].split(','), '0')
assert newline == ' TX:SPEED,0,0,10,20,30,40', '改開關時動到了別的欄位'
ok('改開關只動那一格(連縮排都留著)')
# ── 二、檔案安全。以下每一組都在暫存資料夾裡做,不碰遊戲檔 ─────
d = tempfile.mkdtemp(prefix='fel_selftest_')
outside = os.path.join(d, 'OUTSIDE_不可以被動到.txt')
OUTSIDE_BODY = b'this file lives outside and must never be touched'
try:
# (1) 備份:先在猜得到的 <備份>.part 名字上放一個指向外面的符號連結。
# 舊版會順著它把外面那個檔截斷;現在的 mkstemp 名字是隨機的,
# 這個連結從頭到尾沒有人碰。
box = os.path.join(d, 'box'); os.mkdir(box)
game = os.path.join(box, 'ingame.big')
with open(game, 'wb') as f:
f.write(b'BIGF' + b'\x00' * 60)
with open(outside, 'wb') as f:
f.write(OUTSIDE_BODY)
bak = game + '.feltoolbak'
os.symlink(outside, bak + '.part') # 餌
_atomic_copy(game, bak)
assert open(outside, 'rb').read() == OUTSIDE_BODY, \
'備份順著 <備份>.part 符號連結把資料夾外面的檔案截斷了'
assert open(bak, 'rb').read() == open(game, 'rb').read(), '備份內容不對'
assert os.path.islink(bak + '.part'), '餌被動到了(它應該原封不動)'
os.remove(bak + '.part')
ok('備份不理會猜得到的 <備份>.part 符號連結')
# (2) 寫入遊戲檔:同一個餌換成 <遊戲檔>.tmp
os.symlink(outside, game + '.tmp') # 餌
_atomic_write_bytes(game, b'BIGF' + b'\x11' * 60)
assert open(outside, 'rb').read() == OUTSIDE_BODY, \
'寫入順著 <遊戲檔>.tmp 符號連結把資料夾外面的檔案截斷了'
assert open(game, 'rb').read() == b'BIGF' + b'\x11' * 60, '寫入的內容不對'
assert game in _REPLACED, '換掉了檔案卻沒有記下來(Ctrl-C 的訊息會說謊)'
os.remove(game + '.tmp')
ok('寫入不理會猜得到的 <遊戲檔>.tmp 符號連結,而且會記錄「換過了」')
# (3) 還原到一半失敗:正本必須原封不動。
# 用 monkeypatch 讓 os.replace 丟例外 —— 那正是「換名前一刻斷掉」。
before = open(game, 'rb').read()
real_replace = os.replace
def _boom(*a, **k):
raise OSError('selftest:假裝在換名這一刻斷掉')
os.replace = _boom
try:
_do_copy(bak, game)
except OSError:
pass
else:
raise AssertionError('還原途中出錯卻沒有丟例外')
finally:
os.replace = real_replace
assert open(game, 'rb').read() == before, \
'還原失敗卻動到了正本(這正是舊版 copy2 會做的事)'
assert not [x for x in os.listdir(box) if x.startswith('.')], \
'失敗之後留下了暫存檔'
assert _REPLACED.count(game) == 1, \
'os.replace 丟 OSError(換名確定沒發生)卻多記了一筆' # 只有 (2) 那一次
assert game not in _REPLACING, \
'換名失敗了,「正在換」那一筆卻沒有清掉(收尾會說它可能換過了)'
ok('還原中途失敗:正本原封不動、暫存檔清乾淨、兩張登記表都對')
# (4) 還原時內容對不上:一樣不可以換上去。
# 讓複製寫出錯的位元組,sha256 那一關就該把它擋下來。
real_copyfileobj = shutil.copyfileobj
def _wrong(src, dst_, *a, **k):
dst_.write(b'WRONG')
shutil.copyfileobj = _wrong
try:
_do_copy(bak, game)
except DataError:
pass
else:
raise AssertionError('寫出來的內容跟備份不一樣,卻照樣換上去了')
finally:
shutil.copyfileobj = real_copyfileobj
assert open(game, 'rb').read() == before, '內容對不上卻動到了正本'
ok('還原內容對不上:擋下來,正本原封不動')
# (5) 正常還原:這是陰性對照 —— 沒有它的話,上面兩條就算是
# 「_do_copy 根本沒在做事」也會是綠的。
_do_copy(bak, game)
assert open(game, 'rb').read() == open(bak, 'rb').read(), '正常還原沒有還原成功'
ok('正常還原真的會把備份寫回去(陰性對照)')
# (6) 目標本身是符號連結:停下來,不跟著它走。
link = os.path.join(box, 'link.big')
os.symlink(outside, link)
for fn_, args in ((_atomic_write_bytes, (link, b'x')),
(_do_copy, (bak, link)),
(_atomic_copy, (game, link))):
try:
fn_(*args)
except DataError:
pass
else:
raise AssertionError('目標是符號連結卻照樣寫了下去')
assert open(outside, 'rb').read() == OUTSIDE_BODY, '順著符號連結動到了外面的檔'
ok('目標是符號連結:三個寫入入口都停下來')
# (7) --extract 的輸出檔:指向外面的**斷掉的**符號連結。
# os.path.exists 對它回傳 False,舊版會順著它建檔;
# 現在 lexists + O_EXCL 兩道都會擋。
dangling = os.path.join(box, 'out.txt')
os.symlink(os.path.join(d, '不存在的目標.txt'), dangling)
assert not os.path.exists(dangling) and os.path.lexists(dangling), \
'這個餌沒有布置成功(它應該是一個斷掉的符號連結)'
try:
cmd_extract({'a.fel': 'x'}, 'a.fel', dangling)
except DataError:
pass
else:
raise AssertionError('輸出檔是斷掉的符號連結卻照樣寫了下去')
assert not os.path.exists(os.path.join(d, '不存在的目標.txt')), \
'順著斷掉的符號連結把檔案建到外面去了'
ok('--extract 擋得下斷掉的符號連結')
# (8) 換名那一段不可以被 Ctrl-C 切成兩半。
# 這裡不送真的訊號(Windows 的 os.kill 不接受 SIGINT),而是直接呼叫
# 「這一刻裝著的那個 SIGINT 處理器」—— 使用者按下 Ctrl-C 時,
# Python 做的就是這一件事。餌:沒有 _NoInterrupt 的話,那個處理器
# 是預設的,當場就丟 KeyboardInterrupt,下面那一行永遠跑不到。
before_h = signal.getsignal(signal.SIGINT)
ran = []
try:
with _NoInterrupt():
h = signal.getsignal(signal.SIGINT)
assert h is not before_h, '_NoInterrupt 沒有把 SIGINT 處理器換掉'
h(signal.SIGINT, None) # 等同使用者在這一刻按下 Ctrl-C
ran.append('這一段跑完了') # 舊寫法會在上一行就跳出去
except KeyboardInterrupt:
ran.append('離開之後才丟出來')
else:
raise AssertionError('_NoInterrupt 把 Ctrl-C 吃掉了,離開之後沒有補丟')
assert ran == ['這一段跑完了', '離開之後才丟出來'], \
'Ctrl-C 落在換名那一段:應該先記著、離開才丟,實際是 %s' % ran
assert signal.getsignal(signal.SIGINT) is before_h, \
'離開之後沒有把原本的 SIGINT 處理器裝回去'
ok('Ctrl-C 落在換名那一段:先記著、離開才丟,處理器也裝得回去')
# (9) 三態登記:換名成功之後,那個檔只能出現在「已換」那一張表裡。
# 這是 (8) 的陰性對照 —— 沒有它的話,「正在換」永遠是空的
# 也會是綠的(那代表登記根本沒在做事)。
moved = os.path.join(box, 'threestate.big')
with open(moved, 'wb') as f:
f.write(b'BIGF' + b'\x00' * 4)
assert moved not in _REPLACED and moved not in _REPLACING, '餌沒有布置乾淨'
_atomic_write_bytes(moved, b'BIGF' + b'\x22' * 4)
assert moved in _REPLACED, '換過了卻沒有登記「已換」'
assert moved not in _REPLACING, '換完了「正在換」那一筆卻沒有移走'
ok('三態登記:換完只留在「已換」那一張表裡')
finally:
shutil.rmtree(d, ignore_errors=True)
# ── 三、讀者最容易撞到的那些守門,一道一道下餌(2026-09-26 加)──────
# 內容太長,放在下一個函式裡;見那裡的說明。
_selftest_reader_baits(ok)
print('自我測試:%d 組全部通過' % len(passed))
for i, w in enumerate(passed, 1):
print(' %d. %s' % (i, w))
return 0
def _selftest_reader_baits(ok):
"""讀者最容易撞到的那些守門,一道一道下餌(2026-09-26 加)。
為什麼要有這一段:2026-09-24 把這支腳本自我測試以外的 64 道守門逐一拆掉
(條件改成永遠不成立)再跑 --selftest,會變紅的只有 2 道。路徑給錯、檔名打錯、
封裝檔被截斷、備份是半截的、寫回去之後複驗對不上……這些讀者真的會撞到的情況,
守門都在,但沒有任何一條測試證明它們擋得住 —— 哪天被改壞了也不會有人知道。
做法:在暫存資料夾裡造一個假的遊戲資料夾(一個封裝檔裝一個版面檔、一份總設定檔、
一份字串表),然後**照讀者打指令的樣子**跑 main(),一次只放一個毛病進去。
(命令列走不到的兩處 —— 最底層的還原檢查、改攝影機的行數那一道 —— 直接餵那個函式。
解壓函式那幾道走得到:版面檔那幾條路會把解不開的項目跳過(--list 標成「解不開」),
總設定檔那條路不會,所以那一組從 --cameras 下餌。
2026-09-26 訂正:原本這裡把解壓函式也算成走不到。
檔尾 __main__ 那幾道 —— 錯誤印成一句「❌ …」、沒有權限寫入、Ctrl-C 的收尾 ——
run() 也走不到,那幾個餌另開一個 python 從命令列真的跑,見 cli。2026-09-26 加。)
擋在寫入之前的餌(refuse)同時驗三件事:
(a) 印出來的是**那一道**守門自己的那句話 —— 只看「有擋下來」不夠:
後面常常還有別道守門,拆掉這一道之後換別道擋下來,只看結束碼照樣是綠的,
等於沒有替這一道下到餌;
(b) 結束碼是預期的那一個(多數是 1;參數寫錯的那幾種是 2);
(c) 遊戲資料夾裡每一個檔一個位元組都沒變,也沒有多出任何檔(備份、暫存檔)。
只驗 (b)(c) 會把「程式一啟動就死、檔案當然沒被動」讀成「擋下來了」,
所以一開頭先跑陰性對照:同一個假資料夾,正常的指令要照常跑完、寫得進去、還原得回來。
「寫進去之後的複驗」那幾道比較特別:正常情況下它們不會亮,要亮得先讓寫入出錯。
那幾個餌暫時把寫檔的函式換成「寫歪一點」的版本(多一個位元組、目錄少一項、
內容差一個字……),驗的是複驗抓得到、訊息叫你還原、照著還原真的回得去。
⚠️ 不是每一道守門都有餌,拿不到的寫在 selftest() 上方那段說明裡。
"""
import contextlib
import io
import subprocess
# 假的版面檔:兩個畫面、一個可開關的 TX、一個已經關掉的 RT、一個沒有開關欄的 VR。
FEL = ('SC:HUD,1,0,0,0,640,480\r\n'
' GR:BOX,1,0,0,0,640,480\r\n'
' TX:SPEED,1,0,10,20,30,40,font.ffn,5,0\r\n'
' RT:BAR,0,0,1,2,3,4\r\n'
' VR:78\r\n'
'SC:MENU,1,0,0,0,640,480\r\n'
' TX:TITLE,1,0,5,5,100,20,font.ffn,6,0\r\n'
'END:\r\n').encode('latin-1')
# 假的總設定檔:兩張欄位名稱表(打擊、守備),照真的那樣各管它後面那一段。
# BattingView3 到 6 各自帶一個毛病,給攝影機那一組餌用:
# 3 = X 那一格裝的是字串 · 4 = 少了 FOV 那一欄 · 5 = FOV 後面多一個空白
# 6 = FOV 那一欄出現兩次
DF = ('0x1 3<Settings>;1 a;2 b;3 c\n'
'0x10 7 0;1 X;2 Y;3 Z;4 Label;5 FOV;6 Pitch;7 Heading\n'
'0x11 7<BattingView1>;1 1.0;2 2.0;3 3.0;4 X#Y#Z;5 30.0;6 0.0;7 0.0\n'
'0x12 7<BattingView2>;1 4.0;2 5.0;3 6.0;4 X#Y#Z;5 35.0;6 0.0;7 0.0\n'
'0x13 7<BattingView3>;1 X#Y#Z;2 5.0;3 6.0;4 X#Y#Z;5 35.0;6 0.0;7 0.0\n'
'0x14 6<BattingView4>;1 4.0;2 5.0;3 6.0;4 X#Y#Z;6 0.0;7 0.0\n'
'0x15 7<BattingView5>;1 4.0;2 5.0;3 6.0;4 X#Y#Z;5 30.0 ;6 0.0;7 0.0\n'
'0x16 8<BattingView6>;1 4.0;2 5.0;3 6.0;4 X#Y#Z;5 30.0;5 30.0;6 0.0;7 0.0\n'
'0x20 6 0;1 OffsetX;2 OffsetY;3 OffsetZ;4 Label;5 FOV;6 Roll\n'
'0x21 6<FieldingView1>;1 0.00;2 3.00;3 0.00;4 OffsetX#OffsetY#OffsetZ;5 40.0;6 0.0\n'
'0x30 2<Other>;1 MARK;2 z\n').encode('latin-1')
IG = os.path.join('data', 'frontend', 'ingame.big')
DFB = os.path.join('data', 'datafile', 'datafile.big')
DFT = os.path.join('data', 'datafile', 'datafile.txt')
TOGGLE = ['--toggle', 'fes_test.fel', 'SPEED', 'off', '--apply']
def cam(where, name, sets):
return ['--big', where, '--camera', name, '--set', sets, '--apply']
def big(entries, order='<'):
"""造一個格式合法的封裝檔:16 位元組檔頭、目錄(位移與長度都是大端)、資料。"""
toc_len = sum(8 + len(n) + 1 for n, _ in entries)
off, toc, body = 16 + toc_len, b'', b''
for n, p in entries:
toc += struct.pack('>II', off, len(p)) + n.encode('latin-1') + b'\x00'
body += p
off += len(p)
return (b'BIGF' + struct.pack(order + 'I', off)
+ struct.pack('>II', len(entries), 16 + toc_len) + toc + body)
def loc(strings):
"""造一個最小的字串表(LOCH → LOCI 索引 → LOCL 文字),給 --strings 用。"""
sids = sorted(strings)
texts = [strings[s].encode('utf-16-le') + b'\x00\x00' for s in sids]
loci = (b'LOCI' + struct.pack('<II', 12 + 4 * len(sids), len(sids))
+ b''.join(struct.pack('<I', (i << 16) | s) for i, s in enumerate(sids)))
offs, pos = [], 16 + 4 * len(sids)
for t in texts:
offs.append(pos)
pos += len(t)
locl = (b'LOCL' + struct.pack('<I', pos) + b'\x00' * 4 + struct.pack('<I', len(sids))
+ b''.join(struct.pack('<I', o) for o in offs) + b''.join(texts))
return b'LOCH' + b'\x00' * 16 + loci + locl
def put(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'wb') as f:
f.write(data)
def size(root, rel):
return os.path.getsize(os.path.join(root, rel))
d = tempfile.mkdtemp(prefix='fel_selftest_')
made = [0]
def game(datafile='big', order='<', ingame=None):
"""一個假的遊戲資料夾。datafile:'big' 放 datafile.big、'loose' 放散裝的 datafile.txt。"""
made[0] += 1
root = os.path.join(d, 'game%02d' % made[0])
put(os.path.join(root, IG), ingame if ingame is not None else big(
[('other.bin', b'NOTAFEL!' * 4), ('fes_test.fel', qfs_compress_literal(FEL))], order))
put(os.path.join(root, 'data', 'FEENG.LOC'), loc({5: 'SPEED!', 6: 'TITLE!'}))
os.makedirs(os.path.join(root, 'data', 'datafile'))
if datafile == 'big':
put(os.path.join(root, DFB),
big([('other.bin', b'x' * 8), ('datafile.txt', qfs_compress_literal(DF))]))
elif datafile == 'loose':
put(os.path.join(root, DFT), DF)
return root
def run(*argv):
"""照讀者在命令列打的樣子跑一次 main()。回傳 (結束碼, 印出來的全部文字)。
結束碼照檔尾 __main__ 那一段對應:DataError 印「❌ 訊息」、結束碼 1;
SystemExit('訊息')(_restore_from_backup 走這個出口)結束碼 1;argparse 是 2。
其他例外不接 —— 那代表程式當掉,就讓自我測試跟著紅。
"""
old, buf = sys.argv, io.StringIO()
sys.argv = ['mvp_fel_toolkit.py'] + list(argv)
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
try:
rc = main()
except DataError as e:
print('\n❌ %s' % e)
rc = 1
except SystemExit as e:
rc = 0 if e.code is None else (e.code if isinstance(e.code, int) else 1)
print('' if isinstance(e.code, int) or e.code is None else e.code)
finally:
sys.argv = old
return rc, buf.getvalue()
def snap(root):
"""資料夾底下每一個檔(含隱藏的暫存檔)的內容。拿來證明沒動到、也沒多出東西。"""
out = {}
for dp, _dn, fns in os.walk(root):
for fn in fns:
with open(os.path.join(dp, fn), 'rb') as f:
out[os.path.relpath(os.path.join(dp, fn), root)] = f.read()
return out
def accept(root, argv, says):
"""陰性對照:這一條要照常跑完。"""
rc, out = run(root, *argv)
assert rc == 0 and says in out, \
'陰性對照「%s」沒有照常跑完(結束碼 %r),後面的餌都不算數:\n%s' % (' '.join(argv), rc, out)
return out
def cli(*argv, head=None):
"""從命令列真的跑一次(另開一個 python 跑這支腳本)。回傳 (結束碼, 印出來的全部文字)。
2026-09-26 加。run() 是直接叫 main(),例外由 run() 自己接 —— 檔尾 __main__
那一段(DataError 印成一句「❌ …」、PermissionError 講人話、Ctrl-C 的收尾)它走不到,
拆掉那幾道 run() 也不會知道。這一支才是讀者真的看到的那一層。
head:放在參數前面、取代「這支腳本的路徑」的那幾個(Ctrl-C 那組用 -c 包一層)。
"""
env = dict(os.environ, PYTHONIOENCODING='utf-8') # 輸出一律 UTF-8,不看這台的語系
r = subprocess.run([sys.executable, '-I', '-X', 'utf8', '-B'] + (head or [os.path.abspath(__file__)]) + list(argv),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, env=env, timeout=120)
return r.returncode, r.stdout.decode('utf-8', 'replace')
@contextlib.contextmanager
def chmodded(path, mode):
"""暫時把 path 的權限改成 mode,離開時一定改回來(不改回來,暫存資料夾會刪不掉)。"""
keep = os.stat(path).st_mode & 0o7777
os.chmod(path, mode)
try:
yield
finally:
os.chmod(path, keep)
def refuse(root, argv, says, rc_want=1, lock=None, runner=None):
"""餌:要被那一道守門擋下來 —— 那一句話、那個結束碼、而且一個位元組都沒動。
lock=(路徑, 權限):跑的那一段時間把那個檔或資料夾的權限改掉(2026-09-26 加)。
runner=cli:從命令列真的跑,不是直接叫 main()。
"""
before = snap(root)
with chmodded(*lock) if lock else contextlib.nullcontext():
rc, out = (runner or run)(root, *argv)
assert rc == rc_want, '「%s」結束碼應該是 %d,實際是 %r:\n%s' % (' '.join(argv), rc_want, rc, out)
assert says in out, '「%s」應該印出「%s」,實際印的是:\n%s' % (' '.join(argv), says, out)
assert 'Traceback' not in out, '「%s」噴出了一整片英文的錯誤訊息:\n%s' % (' '.join(argv), out)
assert snap(root) == before, '「%s」擋下來了,卻動到了遊戲資料夾裡的檔(或多留了檔)' % ' '.join(argv)
def lists(root, argv, says, lock=None):
"""餌(不該停的):遇到壞掉的項目照樣做完、把它標出來,一個位元組都沒動(2026-09-26 加)。
lock=(路徑, 權限):跟 refuse() 一樣,只在跑的那一段改權限;前後兩次拍照都在外面,
不然權限設成 0 的那個檔,拍照的時候自己就讀不到了。
"""
before = snap(root)
with chmodded(*lock) if lock else contextlib.nullcontext():
out = accept(root, argv, says)
assert 'Traceback' not in out, '「%s」噴出了一整片英文的錯誤訊息:\n%s' % (' '.join(argv), out)
assert snap(root) == before, '「%s」是唯讀的,卻動到了遊戲資料夾裡的檔' % ' '.join(argv)
return out
def perms_bite(kind):
"""這台機器上權限擋不擋得住(2026-09-26 加)。kind='read':權限設成 0 的檔還打不打得開;
'write':設成唯讀的資料夾裡還能不能開新檔。
Windows 的 chmod 只管唯讀旗標、系統管理員(root)什麼都打得開、有些檔案系統不記權限
(推論:例如 exFAT 格式的隨身碟)—— 這些情況下權限的餌放不下去,
硬放的話它會「照常跑完」,那是這台機器的性質,不是守門壞了。所以先試,擋不住就跳過並照實印出來。
"""
if os.name == 'nt':
return False
p = os.path.join(d, 'perm_probe_' + kind)
if kind == 'read':
put(p, b'x')
with chmodded(p, 0):
try:
open(p, 'rb').close()
except OSError:
return True
return False
os.mkdir(p)
with chmodded(p, 0o555):
try:
open(os.path.join(p, 'x'), 'wb').close()
except OSError:
return True
return False
def caught(root, argv, says, undo):
"""餌(寫入之後的複驗):寫歪了要被抓到、叫你還原,而且照著還原真的回得去。"""
before = snap(root)
rc, out = run(root, *argv)
assert rc == 1 and says in out, \
'「%s」寫歪了,複驗應該印「%s」並以結束碼 1 停下來,實際(%r):\n%s' % (' '.join(argv), says, rc, out)
assert undo[-1] in out, '「%s」抓到了,卻沒有叫你跑 %s' % (' '.join(argv), undo[-1])
rc2, out2 = run(root, *undo)
after = snap(root)
assert rc2 == 0 and all(after.get(k) == v for k, v in before.items()), \
'照著訊息跑 %s 之後,沒有回到動手之前(結束碼 %r):\n%s' % (undo[-1], rc2, out2)
extra = sorted(set(after) - set(before))
assert len(extra) == 1 and extra[0].endswith('bak'), '還原之後除了備份檔還多了:%s' % extra
@contextlib.contextmanager
def swapped(name, fake):
"""暫時把這支腳本裡的某個函式換掉(寫歪、算錯),離開時一定換回來。"""
g = globals()
real = g[name]
g[name] = fake
try:
yield real
finally:
g[name] = real
def writer(mangle):
"""一個「寫歪一點」的寫檔函式:把要寫的內容先經過 mangle 再真的寫下去。"""
real = _atomic_write_bytes
return lambda path, data: real(path, mangle(bytes(data)))
def repack(b, name, blob):
"""把 blob 接到檔尾、讓目錄裡 name 那一項指過去,檔頭的總大小照原本的位元組順序改。"""
field = [x[1] for x in list_entries(b) if x[0] == name][0]
out = bytearray(b + blob)
struct.pack_into('>II', out, field, len(b), len(blob))
struct.pack_into(size_field_order(b) + 'I', out, 4, len(out))
return bytes(out)
def rewrite(name, old, new):
"""寫歪的一種:name 那一項的內容被換掉一小段,其餘(目錄、檔頭)都一致。"""
def mangle(b):
_n, _f, off, n = [x for x in list_entries(b) if x[0] == name][0]
text = qfs_decompress(b[off:off + n])
assert old in text, '餌沒有布置成功:%s 裡找不到 %r' % (name, old)
return repack(b, name, qfs_compress_literal(text.replace(old, new, 1)))
return mangle
def also(edit):
"""算錯的一種:改攝影機時順手多改了別的地方(換掉 _apply_sets 用)。"""
real = _apply_sets
return lambda text, name, sets: (lambda r: (edit(r[0]), r[1]))(real(text, name, sets))
count = [0]
def group(what, skipped=None):
"""一組餌跑完:記下這一組實際跑了幾個,歸零再開下一組。
skipped=(個數, 原因):這台機器放不下去而跳過的餌(2026-09-26 加)。
跳過的不算進「N 個餌」,而且照實印出來 —— 不可以讓它看起來像通過。
"""
note = ['%d 個餌' % count[0]] if count[0] else []
if skipped and skipped[0]:
note.append('跳過 %d 個:%s' % skipped)
ok('%s(%s)' % (what, '、'.join(note)) if note else what)
count[0] = 0
def bait(fn, *a, **k):
r = fn(*a, **k)
count[0] += 1
return r
try:
# ── 陰性對照一:不動遊戲檔的模式都跑得起來,遊戲資料夾一個位元組都沒變 ──
# 沒有這一條,下面每一個「擋下來、沒動到」都可能只是這個假資料夾根本讀不動。
g0 = game()
before = snap(g0)
outp = os.path.join(d, 'extracted.txt')
for argv, says in (
(['--list'], 'fes_test.fel'),
(['--entries'], '封裝檔內共 2 個項目'),
(['--bigs'], '合計 1 個封裝檔'),
(['--find', 'SPEED'], '找到 1 個元素'),
(['--show', 'fes_test.fel', '--screen', 'HUD'], '畫面 HUD'),
(['--strings', 'fes_test.fel'], 'SPEED!'),
(['--where', 'fes_test.fel'], '共 2 個'),
(['--extract', 'fes_test.fel', outp], '已存成'),
(['--big', DFB, '--cameras'], '這個檔裡有 7 個攝影機'),
(['--big', DFB, '--camera', 'BattingView1'], '可改的欄位'),
(['--big', DFB, '--camera', 'BattingView1', '--set', 'FOV=31.5'], '以上只是預覽'),
(TOGGLE[:-1], '以上只是預覽')):
accept(g0, argv, says)
assert snap(g0) == before, '唯讀模式或沒加 --apply 的預覽動到了遊戲資料夾'
assert open(outp, 'rb').read() == FEL, '--extract 存出來的不是那個版面檔'
group('陰性對照:假遊戲資料夾上十二條不動遊戲檔的指令照常跑完,遊戲資料夾一個位元組都沒變')
# ── 陰性對照二:會寫檔的三條路都走得通,還原也回得去 ───────────────
# 2026-09-26 改:每一條路都連改兩次。檔頭說「之後再改幾次都保留最早那一份,
# 所以 --restore 永遠回到你動手之前」—— 只改一次的話,拆掉「備份已存在就不再備份」
# 那一道,這一組照樣是綠的(實測開關那一條就是這樣):第二次 --apply 會拿
# 「改過一次」的檔去蓋掉備份,--restore 只回得到改過一次的樣子,而且還原時照樣印
# 「位元組完全一致:✅ 是」—— 它比的是備份,不是動手之前。所以第二次之後直接驗備份。
for order in ('<', '>'): # 檔頭大小欄小端、大端各一次
g1 = game(order=order)
orig = snap(g1)
accept(g1, TOGGLE, '✅ 複驗通過')
assert snap(g1)[IG] != orig[IG], '說寫入成功,檔案卻沒變'
accept(g1, ['--toggle', 'fes_test.fel', 'TITLE', 'off', '--apply'], '保留最早那一份')
assert snap(g1)[IG + '.feltoolbak'] == orig[IG], '開關改第二次之後,備份已經不是動手之前那一份'
accept(g1, ['--restore'], '位元組完全一致:✅ 是')
assert snap(g1)[IG] == orig[IG], '還原之後跟動手之前不一樣'
g2 = game()
orig = snap(g2)
accept(g2, cam(DFB, 'BattingView1', 'FOV=31.5'), '✅ 複驗通過')
accept(g2, cam(DFB, 'BattingView2', 'FOV=36.5'), '保留最早那一份')
assert snap(g2)[DFB + '.datafilebak'] == orig[DFB], '總設定檔(封裝版)改第二次之後,備份已經不是動手之前那一份'
accept(g2, ['--big', DFB, '--restore-datafile'], '相同 ✅')
assert snap(g2)[DFB] == orig[DFB], '總設定檔(封裝版)還原之後跟動手之前不一樣'
g3 = game('loose')
orig = snap(g3)
accept(g3, cam(DFT, 'BattingView1', 'FOV=31.5'), '讀回來的內容與預期相同 ✅')
accept(g3, cam(DFT, 'FieldingView1', 'OffsetX=1.5'), '保留最早那一份')
assert snap(g3)[DFT + '.datafilebak'] == orig[DFT], '總設定檔(散裝版)改第二次之後,備份已經不是動手之前那一份'
accept(g3, ['--big', DFT, '--restore-datafile'], '相同 ✅')
assert snap(g3)[DFT] == orig[DFT], '總設定檔(散裝版)還原之後跟動手之前不一樣'
group('陰性對照:開關(小端、大端)、改攝影機(封裝版、散裝版)各連改兩次,'
'第二次不重做備份,還原回得到第一次動手之前')
# ── 路徑給錯:第一個參數不是遊戲資料夾 ──────────────────────────
notgame = os.path.join(d, 'not_a_game')
os.mkdir(notgame)
bait(refuse, notgame, ['--list'], '請確認第一個參數是遊戲資料夾', 2)
bait(refuse, notgame, ['--bigs'], '第一個參數是不是給錯資料夾了')
group('路徑給錯:不是遊戲資料夾就停下來講清楚')
# ── 封裝檔本身壞掉或被截斷。開關那幾個加了 --apply,證明連寫都不會寫 ──
bait(refuse, game(ingame=b'SHPI' + b'\x00' * 60), TOGGLE, '這不是 EA 封裝檔')
bait(refuse, game(ingame=b'BIGF' + struct.pack('<I', 16) + struct.pack('>II', 0, 16)),
TOGGLE, '目錄項目數異常')
bait(refuse, game(ingame=big([('other.bin', b'NOTAFEL!' * 4)])), ['--list'], '裡沒有任何版面檔')
# 目錄說有 2 項,檔案在第 1 項之後就結束了
stub = struct.pack('>II', 2, 30) + struct.pack('>II', 0, 16) + b'a.fel\x00'
bait(refuse, game(ingame=b'BIGF' + struct.pack('<I', 30) + stub), ['--list'], '目錄在第 2 項處被截斷')
# 第 1 項的名字沒有結束符
stub = struct.pack('>II', 1, 29) + struct.pack('>II', 0, 16) + b'a.fel'
bait(refuse, game(ingame=b'BIGF' + struct.pack('<I', 29) + stub), ['--list'], '的名稱沒有結束符')
# 資料區被截掉最後 10 個位元組(版面檔那一項正好在最後面)
whole = big([('other.bin', b'NOTAFEL!' * 4), ('fes_test.fel', qfs_compress_literal(FEL))])
bait(refuse, game(ingame=whole[:-10]), TOGGLE, '第 2 項的資料範圍超出檔案結尾')
group('封裝檔不是 BIGF、目錄壞掉、被截斷、裡面沒有版面檔:都停下來,加了 --apply 的也不寫')
# ── 壓縮資料壞掉(總設定檔裡那一項)────────────────────────────
# 2026-09-26 改:原本直接餵解壓函式,說明寫「命令列走不到」—— 那句不成立。
# 版面檔那條路(fel_texts)會把解不開的項目跳過,那幾道在那裡不出聲;
# 總設定檔那條路(datafile_text)沒有接住,讀者照樣撞得到。所以照讀者的打法跑:
# datafile.big 裡 datafile.txt 那一項放壞掉的壓縮資料,打 --cameras。
for blob, says in (
(b'\x11\xfb\x00\x00', 'QFS 檔頭不完整'), # 旗標最低位 1:檔頭要 10 個位元組
(b'\x10\xfb\x00', 'QFS 檔頭不完整'), # 旗標最低位 0:檔頭要 5 個位元組
(b'\x11\xfb' + b'\x00' * 4 + struct.pack('>I', MAX_UNCOMPRESSED + 1), '宣稱解壓尺寸異常'),
(bytes([0x10, 0xfb, 0, 0, 3, 0x01, 0x00, 0x41, 0xfc]), '超過檔頭宣稱'), # 宣稱 3 個,吐出 4 個
(bytes([0x10, 0xfb, 0, 0, 5, 0x02, 0x02, 0x41, 0x42, 0xfc]), '反向參照越界')): # 往回 3 格,只有 2 格
gq = game(None)
put(os.path.join(gq, DFB), big([('other.bin', b'x' * 8), ('datafile.txt', blob)]))
bait(refuse, gq, ['--big', DFB, '--cameras'], says)
# 2026-09-26 加:--entries 是「先看看裡面有什麼」,遇到解不開的項目不可以整個停掉,
# 要照樣列完、把那一項標出來。兩種壞法各一個:壓縮資料本身壞掉、
# 解得開但裡面那一層封裝檔的目錄壞掉。拆掉接住它們的那兩道,--entries 會在那一項當場中止。
inner = b'BIGF' + struct.pack('<I', 30) + struct.pack('>II', 2, 30) + struct.pack('>II', 0, 16) + b'a.fel\x00'
for name, blob, says in (('broken.qfs', b'\x10\xfb\x00\x00', 'QFS(解不開:QFS 檔頭不完整'),
('inner.big', qfs_compress_literal(inner), 'QFS → BIGF(目錄讀不出來)')):
bait(lists, game(ingame=big([('other.bin', b'NOTAFEL!' * 4), (name, blob)])), ['--entries'], says)
group('壓縮資料壞掉:總設定檔裡那一項解不開就停下來(檔頭不完整、宣稱太大、吐得比宣稱多、往回抄越界);'
'--entries 遇到解不開的項目照樣列完,把它標出來')
# ── 名字打錯(版面檔、畫面、元素、on/off)─────────────────────────
g = game()
keep = os.path.join(g, 'keep.txt')
put(keep, b'mine')
for argv, says in (
(['--show', 'fes_nosuch.fel'], '封裝檔裡沒有 fes_nosuch.fel'),
(['--show', 'fes_test.fel', '--screen', 'NOPE'], '裡沒有畫面「NOPE」'),
(['--where', 'fes_nosuch.fel'], '封裝檔裡沒有 fes_nosuch.fel'),
(['--strings', 'fes_nosuch.fel'], '封裝檔裡沒有 fes_nosuch.fel'),
(['--extract', 'fes_nosuch.fel', os.path.join(g, 'out.txt')], '封裝檔裡沒有 fes_nosuch.fel'),
(['--extract', 'fes_test.fel', keep], '已經存在,換一個檔名'),
(['--extract', 'fes_test.fel', os.path.join(g, 'no_such_dir', 'out.txt')], '寫不出'),
(['--toggle', 'fes_nosuch.fel', 'SPEED', 'off', '--apply'], '封裝檔裡沒有 fes_nosuch.fel'),
(['--toggle', 'fes_test.fel', 'SPEEDO', 'off', '--apply'], '找不到可開關的元素「SPEEDO」'),
(['--toggle', 'fes_test.fel', 'HUD', 'off', '--apply'], '找不到可開關的元素「HUD」')):
bait(refuse, g, argv, says)
bait(refuse, g, ['--toggle', 'fes_test.fel', 'SPEED', 'of', '--apply'], '第三個參數要是 on 或 off', 2)
group('名字打錯:版面檔、畫面、元素、on/off,輸出檔已存在或資料夾不存在,都停下來而且不寫')
# ── 改攝影機:名字、欄位、值不對 ─────────────────────────────────
bait(refuse, g, ['--cameras'], '這個封裝檔裡沒有 datafile.txt') # 忘了加 --big
bait(refuse, g, cam(DFB, 'BattingVeiw1', 'FOV=31'), '找不到攝影機「BattingVeiw1」。先用 --cameras')
bait(refuse, g, ['--big', DFB, '--camera', 'View1'], '找不到攝影機「View1」。你是不是要找')
gl = game('loose')
for sets in ('FOV=nan', 'FOV=inf', 'FOV=1e400', 'FOV=1_0'):
bait(refuse, gl, cam(DFT, 'BattingView1', sets), '不能是 nan / inf')
bait(refuse, gl, cam(DFT, 'BattingView1', 'FOV=abc'), '的值要是數字')
for name, sets, says in (
('BattingView1', 'Label=1', '不在可改清單裡'),
('FieldingView1', 'X=5', '沒有「X」這一欄'), # 守備視角沒有絕對座標
('BattingView4', 'FOV=31', '這個攝影機沒有 FOV 這一欄'),
('BattingView3', 'X=5', '不是數字 —— 不動它'),
('BattingView6', 'FOV=31', '出現 2 次'),
('BattingView5', 'FOV=31', '跟先前讀到的「30.0」不一致')):
bait(refuse, gl, cam(DFT, name, sets), says)
bait(refuse, gl, cam(DFT, 'BattingView1', 'FOV31'), '要寫成「欄位=值」', 2)
# 行數守門從命令列走不到(參數會先被去掉頭尾空白),直接餵:值的尾巴帶一個換行
try:
_apply_sets(DF.decode('latin-1'), 'BattingView1', [('FOV', '31\n')])
except DataError as e:
assert '行數改變了' in str(e), '值裡夾了換行,擋是擋了,說的卻是「%s」' % e
else:
raise AssertionError('值裡夾了換行,改完行數變了卻沒有被擋下來')
count[0] += 1
gc = game(None)
put(os.path.join(gc, DFT), b'0x11 3<BattingView1>;1 1.0;2 2.0;3 3.0\n0x10 3 0;1 X;2 Y;3 Z\n')
bait(refuse, gc, ['--big', DFT, '--cameras'], '前面找不到任何欄位名稱表')
gc = game(None)
put(os.path.join(gc, DFT), b'0x1 3<Settings>;1 a;2 b;3 c\n0x30 2<Other>;1 MARK;2 z\n')
bait(refuse, gc, ['--big', DFT, '--cameras'], '這個檔裡找不到任何攝影機')
group('改攝影機:名字打錯、欄位不能改、值不是數字或是 nan/inf、格子本來不是數字,都不寫')
# ── 寫進去之後的複驗(開關):讓寫入出錯,看它抓不抓得到 ──────────
with swapped('_atomic_write_bytes', writer(lambda b: b + b'\x00')):
bait(caught, game(), TOGGLE, '兩種位元組順序都對不上', ['--restore'])
with swapped('_atomic_write_bytes', writer(lambda b: b[:4] + struct.pack('<I', len(b)) + b[8:])):
bait(caught, game(order='>'), TOGGLE, '寫成了 little-endian,原檔是 big-endian', ['--restore'])
with swapped('_atomic_write_bytes',
writer(lambda b: b[:8] + struct.pack('>I', struct.unpack('>I', b[8:12])[0] - 1) + b[12:])):
bait(caught, game(), TOGGLE, '寫入後項目數變了', ['--restore'])
with swapped('_atomic_write_bytes', writer(lambda b: repack(b, 'fes_test.fel', b'\x10\xfb\x00'))):
bait(caught, game(), TOGGLE, '寫入後讀不回 fes_test.fel', ['--restore'])
with swapped('_atomic_write_bytes', writer(rewrite('fes_test.fel', b'BAR,0', b'BAR,1'))):
bait(caught, game(), TOGGLE, '寫回的內容與預期不符', ['--restore'])
with swapped('set_visibility', lambda raw_line, parts, value: raw_line): # 算錯:其實沒改
bait(caught, game(), TOGGLE, '行沒套用成功', ['--restore'])
with swapped('set_visibility', lambda raw_line, parts, value: raw_line + '\r\n XX:EXTRA,1'):
bait(refuse, game(), TOGGLE, '行數改變了') # 這一道在備份之前
group('開關寫進去之後的複驗:大小、位元組順序、項目數、讀不回、內容、沒套用、行數')
# ── 寫進去之後的複驗(改攝影機)─────────────────────────────────
undo_b = ['--big', DFB, '--restore-datafile']
set_b = cam(DFB, 'BattingView1', 'FOV=31.5')
with swapped('_atomic_write_bytes', writer(lambda b: b + b'\x00')):
bait(caught, game(), set_b, '對不上,請立刻 --restore-datafile', undo_b)
with swapped('_atomic_write_bytes',
writer(lambda b: b[:8] + struct.pack('>I', struct.unpack('>I', b[8:12])[0] - 1) + b[12:])):
bait(caught, game(), set_b, '寫入後項目數變了', undo_b)
with swapped('_atomic_write_bytes', writer(rewrite('datafile.txt', b';1 MARK', b';1 MARX'))):
bait(caught, game(), set_b, '讀回來的內容跟預期不符', undo_b)
with swapped('_apply_sets', also(lambda t: t.replace('<BattingView2>', '<BattingView9>'))):
bait(caught, game(), set_b, '寫入後攝影機數量變了', undo_b)
with swapped('_apply_sets', also(lambda t: t.replace('<BattingView2>;1 4.0', '<BattingView2>;1 4.5'))):
bait(caught, game(), set_b, '動到的攝影機不是只有', undo_b)
with swapped('_apply_sets', also(lambda t: t.replace('<BattingView1>;1 1.0', '<BattingView1>;1 1.5'))):
bait(caught, game(), set_b, '改動的欄位數不對', undo_b)
with swapped('_atomic_write_bytes', writer(lambda b: b + b'0x31 2<Extra>;1 1;2 2\n')):
bait(caught, game('loose'), cam(DFT, 'BattingView1', 'FOV=31.5'), '讀回來的內容跟預期不符',
['--big', DFT, '--restore-datafile'])
group('改攝影機寫進去之後的複驗:大小、項目數、內容、攝影機數量、多改了別台或別格,加上散裝版的讀回比對')
# ── 還原:備份不存在、不對、半截 ─────────────────────────────────
bait(refuse, game(), ['--restore'], '找不到備份 ingame.big.feltoolbak')
g = game()
put(os.path.join(g, IG + '.feltoolbak'), b'J' * size(g, IG)) # 一樣大、但不是封裝檔
bait(refuse, g, ['--restore'], '這個備份不是本工具建立的')
g = game()
accept(g, TOGGLE, '✅ 複驗通過')
put(os.path.join(g, IG), b'J' * size(g, IG)) # 遊戲檔本身已經不是封裝檔
bait(refuse, g, ['--restore'], '要還原的目標開頭不是 BIGF')
g = game()
accept(g, TOGGLE, '✅ 複驗通過')
with open(os.path.join(g, IG + '.feltoolbak'), 'r+b') as f:
f.truncate(size(g, IG + '.feltoolbak') - 20) # 備份被截掉最後 20 個位元組
bait(refuse, g, ['--restore'], '檔頭說它應該是')
# 檔頭的總大小寫得比實際小(打包器沒更新那一欄)的備份,改走目錄那一條驗。
# 先證明完整的這種備份還原得回去 —— 一律拒絕的話就是「改得進去、還原不回來」。
stale = bytearray(big([('other.bin', b'NOTAFEL!' * 4), ('fes_test.fel', qfs_compress_literal(FEL))]))
struct.pack_into('<I', stale, 4, 16)
g = game()
put(os.path.join(g, IG + '.feltoolbak'), bytes(stale))
accept(g, ['--restore'], '位元組完全一致:✅ 是')
head16 = b'BIGF' + struct.pack('<I', 16)
for bak, says in (
(bytes(stale[:-10]), '的資料指到'), # 資料區被截掉
(head16 + struct.pack('>II', 0, 16) + b'\x00' * 16, '目錄說有 0 個項目'),
(head16 + struct.pack('>II', 2, 30) + struct.pack('>II', 0, 16) + b'a.fel\x00',
'封裝檔的目錄在第 2 項處被截斷'),
(head16 + struct.pack('>II', 1, 29) + struct.pack('>II', 0, 16) + b'a.fel',
'封裝檔第 1 項的名稱沒有結束符')):
g = game()
put(os.path.join(g, IG + '.feltoolbak'), bak)
bait(refuse, g, ['--restore'], says)
g = game()
accept(g, TOGGLE, '✅ 複驗通過')
with swapped('_restore_from_backup', lambda bak, dst: None): # 還原「做了」卻沒寫進去
rc, out = run(g, '--restore')
assert rc == 1 and '還原後的內容跟備份不一樣' in out, \
'還原之後內容跟備份不一樣,卻沒有以結束碼 1 停下來(%r):\n%s' % (rc, out)
count[0] += 1
bait(refuse, game(), ['--big', DFB, '--restore-datafile'], '找不到備份 datafile.big.datafilebak')
g = game()
put(os.path.join(g, DFB + '.datafilebak'), b'J' * size(g, DFB))
bait(refuse, g, ['--big', DFB, '--restore-datafile'], '備份檔開頭不是 BIGF')
for bak, says in (
(b'PK\x03\x04 not a datafile\n', '看起來不像 datafile.txt'),
(DF[:-5], '結尾不是換行'), # 截在最後一行的中間
(DF + b'garbage\n', '最後一行不是一筆完整的記錄'),
(b'\n'.join(DF.split(b'\n', 2)[:2]) + b'\n', '不到一半')): # 只剩前兩行
g = game('loose')
put(os.path.join(g, DFT + '.datafilebak'), bak)
bait(refuse, g, ['--big', DFT, '--restore-datafile'], says)
g = game('loose')
accept(g, cam(DFT, 'BattingView1', 'FOV=31.5'), '讀回來的內容與預期相同 ✅')
with swapped('_restore_from_backup', lambda bak, dst: None):
rc, out = run(g, '--big', DFT, '--restore-datafile')
assert rc == 1 and '還原後內容跟備份不一樣' in out, \
'總設定檔還原之後跟備份不一樣,卻沒有以結束碼 1 停下來(%r):\n%s' % (rc, out)
count[0] += 1
# 最底層的兩道(找不到備份、0 bytes)命令列走不到 —— 前面那幾道會先擋 —— 直接餵
tgt = os.path.join(d, 'restore_target.bin')
put(tgt, b'keep me')
empty = os.path.join(d, 'empty.bak')
put(empty, b'')
# (「備份是 0 bytes」要整句比:拆掉這一道,下面那道「不到一半」也會擋,
# 而它的訊息寫的是「備份只有 0 bytes」—— 只比「0 bytes」會把它當成這一道。)
for bak, says in ((os.path.join(d, 'missing.bak'), '找不到備份'), (empty, '備份是 0 bytes')):
try:
_restore_from_backup(bak, tgt)
except SystemExit as e:
assert says in str(e.code), '壞備份被擋了,但說的是「%s」' % e.code
else:
raise AssertionError('%s 應該被擋下來' % bak)
assert open(tgt, 'rb').read() == b'keep me', '擋下來了卻動到了要還原的目標'
count[0] += 1
group('還原:備份不存在、不是本工具做的、半截、目標不是封裝檔、還原後對不上')
# ── 以下三組是 2026-09-26 加的 ─────────────────────────────────────
# ── 遊戲檔或備份檔是符號連結:開關、改攝影機兩條路各自那兩道 ─────────
# 底下的寫檔函式自己也會擋連結,所以拆掉這兩條路上的那幾道,多半換成別道擋下來
# (印的是「要備份的來源檔是一個符號連結」)—— 只看結束碼會是綠的,要比那一句話。
# 備份檔那一道更要緊:拆掉的話,連結指到的東西會被當成「已經有的備份」,
# 遊戲檔照寫,而那份「備份」根本不是動手之前的樣子。
far = os.path.join(d, 'far_away') # 連結指過去的地方,在遊戲資料夾外面
os.mkdir(far)
for target, argv in ((IG, TOGGLE), (DFB, cam(DFB, 'BattingView1', 'FOV=31.5'))):
g = game()
real = os.path.join(far, 'real_%d' % made[0])
os.rename(os.path.join(g, target), real)
os.symlink(real, os.path.join(g, target))
before_far = snap(far)
bait(refuse, g, argv, '要修改的遊戲檔是一個符號連結')
assert snap(far) == before_far, '擋下來了,卻動到了連結指過去的那個檔'
for target, bak, argv in ((IG, '.feltoolbak', TOGGLE),
(DFT, '.datafilebak', cam(DFT, 'BattingView1', 'FOV=31.5'))):
g = game('loose' if target == DFT else 'big')
decoy = os.path.join(far, 'decoy_%d' % made[0])
put(decoy, b'this is not a backup')
os.symlink(decoy, os.path.join(g, target + bak))
before_far = snap(far)
bait(refuse, g, argv, '備份檔是一個符號連結')
assert snap(far) == before_far, '擋下來了,卻動到了連結指過去的那個檔'
group('遊戲檔或備份檔是符號連結:開關、改攝影機都在動手之前停下來,連結指到的檔一個位元組都沒動')
# ── 封裝檔讀不到(沒有讀取權限)───────────────────────────────────
# 讀檔那一道把「讀不到」接成一句中文;拆掉它,run() 會接不住那個例外,
# 這一組就紅在那裡。權限擋不住的機器上跳過(見 perms_bite)。
if perms_bite('read'):
for argv in (['--list'], TOGGLE):
g = game()
bait(refuse, g, argv, '讀不到', lock=(os.path.join(g, IG), 0))
skip = None
else:
skip = (2, '在 Windows、用系統管理員身分跑、或檔案系統不記權限時,讀取權限的餌放不下去')
group('封裝檔讀不到(沒有讀取權限):停下來講清楚,加了 --apply 的也不寫', skip)
# ── 從命令列真的跑一次:檔尾 __main__ 那三道(DataError、PermissionError、Ctrl-C)──
# 讀者看到的是這一層。DataError 那一道拆掉的話,錯誤會變成一整片英文(Traceback);
# PermissionError 那一道是「資料夾不能寫」時講人話 —— 遊戲裝在 C:\Program Files
# 這種受保護的資料夾時,--apply 做備份就會撞上(Windows 那一面本站還沒實測,
# 這裡在 macOS/Linux 用「資料夾設成唯讀」讓同一個例外出現)。
skips = []
bait(refuse, game(ingame=b'SHPI' + b'\x00' * 60), ['--list'], '\n❌ 檔頭前四碼不是 BIGF,這不是 EA 封裝檔',
runner=cli)
if perms_bite('write'):
g = game()
bait(refuse, g, TOGGLE, '❌ 沒有權限寫入', runner=cli,
lock=(os.path.join(g, 'data', 'frontend'), 0o555))
else:
skips.append('在 Windows、用系統管理員身分跑、或檔案系統不記權限時,寫入權限的餌放不下去')
# 按 Ctrl-C:收尾那一道要照實說動到了沒有,結束碼 130。
# 不送真的訊號 —— Windows 不能這樣送,而且時機抓不準(2026-09-26 試過用具名管道卡住再送,
# 連跑 150 次有 14 次訊號沒生效,那種餌自己就是假的紅燈)。Ctrl-C 在 Python 裡就是
# 「跑到哪,就在哪丟出 KeyboardInterrupt」,所以另開一個 python,讓它在指定的那一刻
# 丟出來,其他照讀者的打法跑到底。兩個時間點各一個:
# · 還沒動手(第一次讀封裝檔)→ 要說「遊戲檔一個位元組都沒有被動到」
# · 已經換過了(寫完之後、複驗要重讀的那一刻)→ 要說換過了、叫你 --restore,
# 絕對不可以說沒動到;照著 --restore 要回得到動手之前
ctrl_c = ('import builtins, os, runpy, sys\n'
'when, real_open, real_replace, done = sys.argv[1], builtins.open, os.replace, []\n'
'def fake_open(p, *a, **k):\n'
' if os.path.basename(str(p)) == "ingame.big" and (when == "first" or done):\n'
' raise KeyboardInterrupt\n'
' return real_open(p, *a, **k)\n'
'def fake_replace(src, dst, *a, **k):\n'
' real_replace(src, dst, *a, **k)\n'
' if os.path.basename(str(dst)) == "ingame.big":\n'
' done.append(dst)\n'
'builtins.open, os.replace = fake_open, fake_replace\n'
'sys.argv = sys.argv[2:]\n'
'runpy.run_path(sys.argv[0], run_name="__main__")\n')
for when, argv in (('first', ['--list']), ('replaced', TOGGLE)):
g = game()
before = snap(g)
rc, out = cli(g, *argv, head=['-c', ctrl_c, when, os.path.abspath(__file__)])
assert rc == 130 and '已中斷' in out and 'Traceback' not in out, \
'Ctrl-C(%s):應該印「已中斷」、以結束碼 130 停下來,實際(%r):\n%s' % (when, rc, out)
if when == 'first':
assert '遊戲檔一個位元組都沒有被動到' in out and '已經換過了' not in out, \
'還沒動手就按 Ctrl-C,卻沒有照實說「沒動到」:\n%s' % out
assert snap(g) == before, '還沒動手就按 Ctrl-C,卻動到了遊戲資料夾裡的檔'
else:
assert '已經換過了' in out and 'ingame.big' in out and '--restore' in out, \
'換過之後才按 Ctrl-C,應該說「已經換過了」並叫你 --restore:\n%s' % out
assert '一個位元組都沒有被動到' not in out, '檔案已經換過了,Ctrl-C 的收尾卻說沒動到:\n%s' % out
assert snap(g)[IG] != before[IG], '餌沒有布置成功:說換過了,檔案卻沒變'
accept(g, ['--restore'], '位元組完全一致:✅ 是')
assert snap(g)[IG] == before[IG], '照著 Ctrl-C 的訊息 --restore 之後,沒有回到動手之前'
count[0] += 1
group('從命令列真的跑一次:出錯只印一句「❌ …」、資料夾不能寫時講人話、按 Ctrl-C 照實說動到了沒有',
(len(skips), ';'.join(skips)))
# ── 唯讀的指令遇到壞掉的資料:照樣跑完、不噴錯誤(2026-09-26 加)──────────
# 這三道都是「接住錯誤、回一個預設值、接著往下做」的寫法:字串表讀不到或被截斷,
# 就當成沒有字串表;版面檔某一行的座標欄不是數字,就跳過那一行。
# 拆掉任何一道,那個錯誤會一路冒出來,--strings / --where 在讀者面前噴一整片英文;
# run() 不接那種錯誤,這一組就紅在那裡。
# (1) 字串表被截斷:LOCL 那一區的位移表只剩第一條(第 2 條的位移被截掉)
whole_loc = loc({5: 'SPEED!', 6: 'TITLE!'})
g = game()
put(os.path.join(g, 'data', 'FEENG.LOC'), whole_loc[:whole_loc.index(b'LOCL') + 20])
bait(lists, g, ['--strings', 'fes_test.fel'], '讀不到字串表')
# (2) 版面檔裡多一行看得見的文字,座標那四欄不是數字:--where 跳過那一行,另外兩個照列
odd = FEL.replace(b'END:', b' TX:ODD,1,0,a,b,c,d,font.ffn,5,0\r\nEND:', 1)
g = game(ingame=big([('other.bin', b'NOTAFEL!' * 4), ('fes_test.fel', qfs_compress_literal(odd))]))
out = bait(lists, g, ['--where', 'fes_test.fel'], '共 2 個')
assert 'TX:ODD' not in out, '座標欄不是數字的那一行不該被列出來:\n%s' % out
# (3) 字串表沒有讀取權限。權限擋不住的機器上跳過(見 perms_bite)。
if perms_bite('read'):
g = game()
bait(lists, g, ['--strings', 'fes_test.fel'], '讀不到字串表',
lock=(os.path.join(g, 'data', 'FEENG.LOC'), 0))
skip = None
else:
skip = (1, '在 Windows、用系統管理員身分跑、或檔案系統不記權限時,讀取權限的餌放不下去')
group('唯讀的指令遇到壞掉的資料:字串表被截斷或沒有讀取權限時 --strings 照樣跑完、說讀不到字串表;'
'版面檔某一行的座標不是數字時 --where 跳過那一行、照樣列完', skip)
finally:
shutil.rmtree(d, ignore_errors=True)
# ─────────────────────────────────────────────────────────
def main():
"""讀參數,決定要做哪一件事。
順序是有講究的,不是隨便排的:
1. --bigs 完全不需要封裝檔路徑,先處理
2. 判斷使用者指的是封裝檔還是散裝的 datafile.txt(官方中文版沒有封裝檔)
3. --entries 與攝影機相關的模式排在「有沒有版面檔」那道關卡**之前**:
圖片、模型、球場、總設定檔一個版面檔都沒有,但一樣該看得到裡面有什麼
4. 其餘版面檔模式
回傳值就是行程的結束碼:0 成功、2 是使用者給錯東西。
"""
ap = argparse.ArgumentParser(
description='MVP Baseball 2005 介面版面檔萬用工具')
ap.add_argument('gamedir', help='遊戲資料夾(裡面要有 data 這個子資料夾)')
ap.add_argument('--big', default=DEFAULT_BIG,
help='要處理的封裝檔,相對於遊戲資料夾(預設 %s)' % DEFAULT_BIG)
ap.add_argument('--list', action='store_true', help='列出封裝檔裡的版面檔')
ap.add_argument('--entries', action='store_true',
help='列出封裝檔裡的每一個項目(不限版面檔,任何封裝檔都能用)')
ap.add_argument('--cameras', action='store_true',
help='列出總設定檔裡的全部攝影機與座標(要搭配 --big data/datafile/datafile.big)')
ap.add_argument('--camera', metavar='名稱',
help='顯示某一個攝影機的全部欄位')
ap.add_argument('--set', metavar='欄位=值,…', dest='cam_set',
help='搭配 --camera,改欄位。白名單有十個名字:'
'X/Y/Z/FOV/Pitch/Heading/Roll,加上守備那張表才有的 '
'OffsetX/OffsetY/OffsetZ。每一台實際能改的是其中七個,'
'而且那一格本來就要是數字')
ap.add_argument('--restore-datafile', action='store_true', dest='restore_datafile',
help='把總設定檔還原成本工具做的備份。有 datafile.big 就還原它;'
'官方中文版沒有那個封裝檔,還原的是散裝的 datafile.txt')
ap.add_argument('--bigs', action='store_true',
help='掃 data/frontend,看哪些封裝檔裝著版面檔(唯讀)')
ap.add_argument('--find', metavar='關鍵字', help='掃全部版面檔,找名字含這個字的元素')
ap.add_argument('--show', metavar='版面檔', help='顯示某個版面檔的內容')
ap.add_argument('--screen', metavar='畫面代號', help='搭配 --show,只看某一個畫面')
ap.add_argument('--extract', nargs=2, metavar=('版面檔', '輸出檔'), help='存成文字檔(唯讀)')
ap.add_argument('--strings', nargs='?', const='', metavar='版面檔',
help='把版面檔裡的字串編號換成真正的文字(唯讀)')
ap.add_argument('--where', metavar='版面檔',
help='列出這個版面檔裡顯示中的元素在畫面上的座標(唯讀)')
ap.add_argument('--toggle', nargs=3, metavar=('版面檔', '元素名', 'on/off'),
help='開關某個元素')
ap.add_argument('--cmd', metavar='指令', help='搭配 --find/--toggle,只針對某種指令(例如 TX)')
ap.add_argument('--lines', type=int, default=80, help='--show 顯示幾行(預設 80)')
ap.add_argument('--apply', action='store_true', help='真的寫入(沒加就只是預覽)')
ap.add_argument('--restore', action='store_true', help='從備份還原')
args = ap.parse_args()
if args.bigs:
cmd_bigs(args.gamedir); return 0
bigpath = os.path.join(args.gamedir, args.big)
# ⚠️ 2026-08-28 加:官方中文版沒有 datafile.big。
# 它把 data/datafile/ 散裝成 466 個 .txt,datafile.txt 就躺在資料夾裡
# (data/anims/ 也一樣,730 個散裝檔沒有 anims.big)。
# 本站在此之前所有量測都是拿英文版做的,所以這一課對裝中文版的人
# 是從頭到尾跑不起來的 —— 工具會在這裡就報「找不到封裝檔」。
# 這一段就是為了那件事:先看使用者指的是不是散裝的 datafile.txt;
# 如果他指的是 datafile.big 而那個檔不存在,就自動找旁邊的 datafile.txt。
loose_path = None
if loose_datafile(bigpath):
loose_path = bigpath
elif not os.path.isfile(bigpath) and os.path.basename(bigpath).lower() == 'datafile.big':
cand = os.path.join(os.path.dirname(bigpath), DATAFILE_ENTRY)
if os.path.isfile(cand):
loose_path = cand
print(' 找不到 %s,但同一個資料夾裡有散裝的 %s —— 改用它。'
% (os.path.basename(bigpath), DATAFILE_ENTRY))
print(' (官方中文版就是這樣:整個 data/datafile/ 是散裝的,沒有封裝檔)')
if not loose_path and not os.path.isfile(bigpath):
print('❌ 找不到 %s' % bigpath)
print(' 請確認第一個參數是遊戲資料夾(裡面應該要有 data 這個子資料夾)。')
print(' 如果你裝的是官方中文版,攝影機在 data/datafile/datafile.txt(散裝,沒有封裝檔)。')
return 2
if args.restore:
cmd_restore(bigpath); return 0
if loose_path:
raw = b''
items = []
else:
raw = load_big(bigpath)
items = list_entries(raw)
# --entries 在「有沒有版面檔」那道關卡之前處理 ——
# 圖片、模型、球場那些封裝檔一個版面檔都沒有,但一樣該看得到裡面有什麼。
if args.entries:
cmd_entries(raw, items); return 0
# 攝影機在 datafile.big,那個檔一個版面檔都沒有,同樣要在關卡之前。
if args.restore_datafile:
cmd_restore_datafile(bigpath, loose_path); return 0
if args.cameras or args.camera:
text = datafile_text(raw, items, loose_path)
if args.cameras and not args.camera:
cmd_cameras(text); return 0
if args.cam_set:
sets = []
for part in args.cam_set.split(','):
if '=' not in part:
print('❌ --set 要寫成「欄位=值」,用逗號分隔多組。你給的是「%s」' % part)
return 2
k, v = part.split('=', 1)
sets.append((k.strip(), v.strip()))
cmd_camera_set(bigpath, raw, items, text, args.camera, sets, args.apply, loose_path)
else:
cmd_camera_show(text, args.camera)
return 0
texts = fel_texts(raw, items)
if not texts:
raise DataError('%s 裡沒有任何版面檔。是不是指定錯封裝檔了?' % os.path.basename(bigpath))
if args.list:
cmd_list(raw, items)
elif args.find:
cmd_find(texts, args.find, args.cmd)
elif args.show:
cmd_show(texts, args.show, args.screen, args.lines)
elif args.extract:
cmd_extract(texts, args.extract[0], args.extract[1])
elif args.where:
cmd_where(texts, load_all_loc(args.gamedir), args.where)
elif args.strings is not None:
cmd_strings(texts, load_all_loc(args.gamedir), args.strings or None)
elif args.toggle:
fn, name, state = args.toggle
if state.lower() not in ('on', 'off'):
print('❌ 第三個參數要是 on 或 off,你給的是「%s」' % state)
return 2
cmd_toggle(bigpath, raw, items, texts, fn, name, state.lower(), args.cmd, args.apply)
else:
cmd_list(raw, items)
print(' 想找某個元素:--find <關鍵字>')
print(' 想看某個檔 :--show <版面檔>')
print(' 想看別的封裝檔:--bigs')
print(' 想知道在畫面哪:--where <版面檔>')
print(' 想關某個元素:--toggle <版面檔> <元素名> off')
return 0
# ─────────────────────────────────────────────────────────
# 「-O 拒跑」那道守門的餌(2026-09-24 加)
#
# 自我測試一開頭有一道守門:在 python -O 底下拒跑(-O 會把 assert 整段拿掉,
# 測試會變成一片假的綠燈)。那一行以前直接問 sys.flags.optimize,
# 而 sys.flags 是唯讀的,同一個程序裡沒辦法把 -O 打開又關掉 ——
# 所以那道守門一直沒有餌:哪天被拆掉,自我測試照樣全綠,沒有人會發現。
# 現在守門改問 _optimize_level(),自我測試就能暫時把它換掉來下餌。
# ─────────────────────────────────────────────────────────
def _optimize_level():
"""python 的 -O 等級:沒加 -O 是 0,加 -O 是 1,加 -OO 是 2。"""
return sys.flags.optimize
class _OptGuardPassed(Exception):
"""_optimize_bait() 用的記號:再叫一次自我測試時,守門放行、走到了下一行。"""
def _optimize_bait(selftest_fn):
"""-O 守門的餌與陰性對照。回傳 (餌被擋下來了, 陰性對照被放行了)。
自我測試在守門的下一行就叫這一支,這一支再叫兩次 selftest_fn(輸出收起來不印):
· 餌:先把 _optimize_level 換成「回傳 1」(假裝開了 -O)。那一次必須在守門
那裡就停下來 —— 結束碼 2,而且印出來的那句話提到 -O。
· 陰性對照:換回原本那一支之後再叫一次,守門必須放行。放行之後的下一行
就是這裡,所以那一次會丟出 _OptGuardPassed 立刻收工 ——
不會把整套自我測試再跑一遍,也不會在磁碟上留下任何東西。
守門被拆掉的話,餌那一次也會一路走到這裡、丟出 _OptGuardPassed,就算沒擋下來。
"""
if getattr(_optimize_bait, 'busy', False):
raise _OptGuardPassed()
import contextlib
import io
global _optimize_level
real = _optimize_level
def once():
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
rc = selftest_fn()
except SystemExit as e:
rc = e.code
except _OptGuardPassed:
rc = '放行'
except Exception as e:
rc = '例外 %r' % (e,)
return rc, buf.getvalue()
_optimize_bait.busy = True
try:
_optimize_level = lambda: 1
try:
rc, said = once()
finally:
_optimize_level = real
rc2, _said2 = once()
finally:
_optimize_bait.busy = False
bait = rc == 2 and '-O' in said
neg = rc2 == '放行' and _optimize_level is real and _optimize_level() == 0
return bait, neg
if __name__ == '__main__':
# --selftest 排在最前面判斷:它不需要遊戲資料夾,
# 走進 main() 反而會因為「沒給路徑」被 argparse 擋下來。
if '--selftest' in sys.argv:
sys.exit(selftest())
try:
sys.exit(main())
except DataError as e:
print('\n❌ %s' % e)
sys.exit(1)
except PermissionError as e:
# 遊戲裝在 C:\Program Files (x86)\EA SPORTS\ 這種受保護的資料夾時,
# --apply 第一步做備份就會撞上 —— 而那正是這個遊戲的預設安裝位置。
# 這裡要講人話,不能讓使用者看到一整片英文 traceback。
print('\n❌ 沒有權限寫入:%s' % e)
print(' 遊戲裝在 C:\\Program Files 這種受保護的資料夾時會這樣。')
print(' 不要用系統管理員身分跑,')
print(' 把整個遊戲資料夾複製到桌面,路徑換成那一份,也從那一份開遊戲。')
print(' (遊戲檔沒有被改到)')
sys.exit(1)
except KeyboardInterrupt:
# ⚠️ 不可以無條件說「沒有改到任何檔案」:寫入(os.replace)是在複驗
# **之前**就完成的,使用者在「已寫入」印出來之後才按 Ctrl-C,
# 檔案已經換掉了。
# 2026-09-05 之前這裡是叫使用者「自己回頭看有沒有印過『已寫入』」——
# 那是把判斷丟回給最慌的那個人。現在程式自己記,而且分三態:
# 還沒動 / 正在換(_REPLACING)/ 已換(_REPLACED),這裡照著講。
print('\n已中斷。')
if _REPLACING:
# 幾乎不會走到這裡(換名跟登記被 _NoInterrupt 包在一起了),
# 它是最後一道保險:寧可說「不知道換成功沒有」,也不要說錯。
print('⚠ 中斷的那一刻正在替換下面這些檔,換成功沒有無法確定:')
for _p in _REPLACING:
print(' · %s' % _p)
print(' 跑一次 --restore 退回動手之前,或自己拿備份比對一下。')
print(' (改攝影機那條路是 --restore-datafile)')
if _REPLACED:
print('⚠ 下面這些檔已經換過了:')
for _p in _REPLACED:
print(' · %s' % _p)
print(' 要退回動手之前:跑一次 --restore')
print(' (改攝影機那條路是 --restore-datafile)')
print(' 這些是換名確定做完的檔,不是「可能」—— 不還原就是改過的狀態。')
elif not _REPLACING:
# 兩張表都是空的才可以講這句話 —— 「正在換」也是動過的一種。
print('遊戲檔一個位元組都沒有被動到 —— 換名那一步還沒發生。')
print('(旁邊可能留著一份完整的備份檔,那是好的,可以留著也可以刪掉。)')
sys.exit(130)
# ─────────────────────────────────────────────────────────
# MIT License
#
# Copyright (c) 2026 toni
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# ─────────────────────────────────────────────────────────