教學 › 改主選單的文字
改主選單的文字
把「Play Now」改成「馬上開打」。 關鍵是不用動版面檔 —— 選單上的字不在那裡, 版面檔只放一個「字串編號」,真正的文字在另一個檔。 改那個檔安全得多,而且改完檔案長度一個位元組都不變。
🛟 動手之前:先完整備份整個遊戲資料夾
把整個遊戲資料夾複製一份到別的地方,
包括裡面存放紀錄的資料夾。紀錄也可能放在遊戲資料夾外面:
「文件」裡的 Documents\MVP Baseball 2005,那一個要另外複製。
怎麼整包複製、紀錄(王朝、名單、設定)的兩個位置各在哪、找不到怎麼找: 看備份與還原 SOP 的「整包複製」。
⚡ 只想趕快改幾個字?照這四步(要先裝好 Python,沒裝的看第 0 課操作五)
- 下載 mvp_menu_text.py(瀏覽器會把它放進「下載」資料夾),
在 MVP_Tools 開命令視窗:Windows 先把下載的腳本搬進
MVP_Tools(第 0 課操作六),再打開檔案總管進到 MVP_Tools,點一下最上面的網址列,把字刪掉改打cmd按 Enter,後面的指令都在開出來的這個視窗打;Mac 開終端機打這一行
cd ~/MVP_Tools - 看主選單有哪些字可以改,以及每一條最多能放幾個字
python -I mvp_menu_text.py "〔遊戲資料夾〕" --list - 預覽。新的字不能比原本長,程式會先幫你算
python -I mvp_menu_text.py "〔遊戲資料夾〕" --set 12=馬上開打 --set 627=離開 - 沒問題就再按 ↑,行尾加
--apply。印出來的「檔案大小」 必須跟原本一模一樣。
後悔的話,這一行還原:
python -I mvp_menu_text.py "〔遊戲資料夾〕" --restore
⚠️ 改成中文之前,你的遊戲要先有中文字型,
不然畫面上會變成空白或方框 ——
看把官方中文版的中文搬進英文版那一課。
上面照 Windows 寫法用 python,Mac 改成 python3。
想知道為什麼「不能改長」,往下讀;只想複習,跳到📌 重點整理。
難度
★☆☆ 新手也做得到
時間
約 5 分鐘
可還原
可,自動備份 + 一行指令還原
選單上的字其實不在版面檔裡
主選單的版面是 data/frontend/frontend.big 裡的
fes_mainmenu.fel。打開來看「Play Now」那一行:
TB:PLAYNOW,1,0,98,188,200,18,1,1,1,1,0,0,twc16_en.ffn,12,0,210,210,180,…
裡面沒有「Play Now」這四個字,只有字型檔名後面那個
12 —— 那是字串編號。真正的文字放在
data/FEENG.LOC 這種字串表裡,格式本站已經
完整解開(UTF-16LE)。
所以這一課其實比想像中安全
「改主選單」聽起來很兇 —— 關錯一項可能整個模式進不去。 但改文字根本不用碰版面檔:版面、按鈕、跳轉全都不動, 只把字串表裡那一條字換掉。選單的結構完全沒變。
⚠ 選單按鈕不是 TX,是 TB
這是做這支工具時踩到的坑,寫出來給後面的人:
第一版只抓 TX(文字)元素,結果在主選單上找到 10 個 ——
而且全部是同一條字串(TITLE/TITLE1,
編號都是 270「Main Menu」)。畫面上印得整整齊齊,看起來很像對,
但真正想改的「Play Now」「Game Modes」一個都沒出現。
去數才發現:主選單有 10 個 TX、31 個 TB
—— 選單上的按鈕是 TB(文字按鈕)。10 + 31 = 41,
跟介面萬用工具數出來的 41 處引用剛好對上。
所以工具改成「任何兩個大寫字母的指令,只要抓得出字串編號就算」, 不靠一份會過期的型別白名單。這也是本站的一條紀律: 兩把尺量出不同答案時,先查哪一把錯。
⚠️ 但 41 是元素的個數,不是字串的條數:這 41 個元素只用到
9 條不同的字串(編號 12、13、14、270、280、627、8140、8180、16306),
同一條被好幾個元素引用時工具只列一次
(編號 270「Main Menu」光這一個版面檔就被引用 10 次),
所以 mvp_menu_text.py --list 印出來是
9 行,不是 41 行。
本站在三份 data/frontend/frontend.big 上量到的都是這個數字:
測試機那一份、剛安裝好的全新英文版、剛安裝好的全新中文版。
為什麼只能改成「同長或更短」
.LOC 的結構是「位移表 + 一整塊連續文字」。
想把某一條改長,就得把它後面每一條的位移全部往後推,
還要重算整塊區域的長度 —— FEENG.LOC 有 6,352 條,
錯一個位移,遊戲讀到的就是別條字串的中間。
所以這支工具走另一條路:原地覆寫,長度不變。
| 好處 | 代價 |
|---|---|
| 檔案總長度一個位元組都不變 | 新文字不能比原本長 |
| 位移表一個位元組都不動 | |
| 你沒點名的 6,349 條保證逐字相同(實測) |
每個槽位能放幾個字,在原廠英文的字串表上
剛好等於原字串的字數。本站量了
兩份 FEENG.LOC:
剛安裝好的原版(415,528 位元組)與本站測試機那一份(416,753 位元組),
兩份都是 6,352 條全部以 2 個 NUL 字元收尾、可用字數等於原字數,
零例外。所以規則很單純:
新文字的字數 ≤ 原文字的字數
⚠️ 這個「零例外」只對那兩份原廠英文表成立,不是 .LOC 這個格式的性質
只要字串表被人改過就不一定。本站那份 2009 年台灣模組的中文
FEENG.LOC(219,421 位元組)有 6,436 條(不是 6,352),
其中 145 條不是「剛好」:114 條空間反而更大、
30 條的可用字數比原字數少一個字(「打擊」「捕手」「全壘打」這種),
還有 1 條算出來的槽位長度是負的。同一包裡的英文那份
(415,840 位元組)也有 5 條不是 2 個 NUL,只是那 5 條空間反而更大。
那 30 條連原本的字都寫不回去。工具會在寫入之前就擋下來,
不會寫壞檔;但你不能自己拿原字數當預算,
一律看 --list 印出來的「可用字數」那一欄。
⭐ 這個限制對「英文改中文」剛好不是問題
中文字少。實測主選單那幾條:
| 編號 | 原本 | 預算 | 改成 | 用掉 |
|---|---|---|---|---|
| 12 | Play Now | 8 字 | 馬上開打 | 4 字 |
| 13 | Game Modes | 10 字 | 比賽模式 | 4 字 |
| 627 | Quit | 4 字 | 離開 | 2 字 |
| 280 | Manage Rosters | 14 字 | — | — |
| 270 | Main Menu | 9 字 | — | — |
反過來想把中文改成長英文就會被擋下來 —— 那是刻意的。
👉 你要做的事
前置:Python 3.7 以上,零相依。 想改成中文的話,遊戲要先有中文字型 (那一課教你搬)。
指令裡的 python 與 python3
下面範例照 Mac 寫法用 python3;Windows 使用者把每行開頭改成 python。
步驟 1:下載 mvp_menu_text.py,搬進 MVP_Tools,在那裡開命令視窗。
Windows:先把下載的腳本搬進 MVP_Tools(第 0 課操作六)。然後打開檔案總管,進到 MVP_Tools,點一下最上面的網址列(整條會反白),把字刪掉改打 cmd 按 Enter。開出來的命令提示字元已經停在 MVP_Tools 裡,後面的指令都在這個視窗打(第 0 課操作一的「資料夾法」)。
Mac:打開終端機,打這一行:
cd ~/MVP_Tools
瀏覽器多半不會問你要存哪裡,它會直接放進「下載」資料夾;請把檔案搬進 MVP_Tools(第 0 課操作六),命令視窗就在 MVP_Tools 裡開(上面那一段)。
命令視窗跟檔案不在同一個資料夾的話,
畫面會出現 can't open file —— 那不是你打錯。
步驟 2:看有哪些字可以改。唯讀。
python3 -I mvp_menu_text.py "〔你的遊戲資料夾〕" --list
想看別的畫面就加 --fel,例如
--fel fes_statsmenu.fel。不知道有哪些?
用介面萬用工具的
--big data/frontend/frontend.big --list ——
--big 不能省:兩邊的預設不一樣。
介面萬用工具預設列的是比賽中那個 ingame.big,
而本課的 --fel 預設是到 frontend.big 裡找;
把 ingame.big 的檔名直接貼過來會被擋下來
(畫面上是「data/frontend/frontend.big 裡沒有〔那個檔名〕」)。
真的想改比賽中的畫面,本課這一邊也要一起加
--big data/frontend/ingame.big。
步驟 3:預覽。可以一次改很多條。
python3 -I mvp_menu_text.py "〔你的遊戲資料夾〕" --set 12=馬上開打 --set 13=比賽模式 --set 627=離開
步驟 4:確定了才真的改。
python3 -I mvp_menu_text.py "〔你的遊戲資料夾〕" --set 12=馬上開打 --apply
2026-10-29 起:同一個編號在選單與比賽中兩張表都有,預設兩張一起改
字串表有兩張:選單那張(FEENG.LOC,官方中文版叫 FEJPN.LOC)和
比賽中那張(IGENG.LOC / IGJPN.LOC)。兩張有 1,310 個共用編號,
預覽表格現在每個編號每張表一列,並在底下提醒「兩張一起改」。
- 只想改一張:編號前面加
FE:(選單)或IG:(比賽中),例如--set IG:18100=日本。 - 回到舊行為(同一個編號只改第一張):加
--first-only。 - 每張表各自量「放不放得下」:同一個編號在兩張表的空間不一定一樣大, 任何一張放不下就整批停住,兩張都不動。
- 不知道編號、只知道畫面上的字:
--find 字(唯讀,大小寫不分,中英文都找)。
舊版(2026-09-24 以前)只改第一張。用過舊版改過字的人,現在重跑一次 --apply 就會補到比賽中那張。
實例:改傳奇隊與英雄隊的名字。
✅ Pass 條件
以下全部滿足才算成功。四項複驗的數字腳本都會印出來, 而且四項只要有一項對不上就會當場停下來,並自動從備份還原 (畫面上是「複驗沒過 —— 現在自動從備份還原」,離開碼 2)。 所以印到最後那句「完成。要還原:--restore」就代表四項都過了。 (2026-09-05 訂正:這一段原本寫「會自動停下來的只有兩項, 字串總數與沒動到的那幾條只印數字、對不上也不會停」。那是舊版的行為, 現在四項都擋。)
- 印出「✓ 已備份:
FEENG.LOC.menutextbak」 (第二次以後改印「· 備份已存在,保留最早那一份」,那也是對的 —— 程式刻意留住最早那一個還原點) - 檔案大小跟原本一模一樣(這是這一課最重要的一條)
- 字串總數跟原本一樣
- 改的複驗印「N/N 正確」
- 沒動到的那幾千條印「逐字相同」, 而且要跟同一行括號裡的「共 N 條沒點名」對得上
- 可能多出一行「共用槽位 編號 N 跟你點名的那幾條是同一個槽位, 會一起變(不是異常)」——那不是錯誤。 字串表的索引項容許兩個編號指到同一格,指到同一格就是同一段文字, 改一個另一個一定跟著變。本站量到的四份原廠字串表都剛好有一組 (編號 0 與編號 8)。腳本會先把這種編號從「沒點名」的分母裡扣掉, 不然改到它們就會被自己的複驗誤判成「檔案壞了」
- 進遊戲看主選單,字真的變了
❌ Fail 處理
| 看到什麼 | 怎麼辦 |
|---|---|
can't open file '…\mvp_menu_text.py' |
命令視窗跟腳本不在同一個資料夾。
打開放著那個 .py 的資料夾(照第 0 課操作六搬過的話就是 MVP_Tools),在網址列打 cmd 按 Enter,用新開的視窗再跑一次(Mac 用 cd 切過去,例如 cd ~/MVP_Tools)。
還是一樣的話,是檔名對不上:瀏覽器可能在檔名後面加了 (1),把它改成跟指令裡一模一樣。
這是本站最常見的第一個卡點。 |
| 「有 N 條放不下」 | 新文字太長。看預覽表的「字數 用掉/預算」欄,改短一點 |
| 「四個字串表裡都找不到編號 N」 | 編號打錯了。先 --list 看正確的編號 |
| 「找不到任何字串表」 | 本工具找 FEENG.LOC/IGENG.LOC/
FEJPN.LOC/IGJPN.LOC 四個。
官方繁中版用的是 JPN 那一對 |
| 「複驗沒過」(檔案大小變了、點名的那幾條沒寫對、 字串總數變了、沒動到的那幾條有出入 —— 四項任一) | 這代表原地覆寫的前提被破壞了,不該發生。
腳本自己就會停下來並自動從備份還原,印
「✓ 已自動還原 FEENG.LOC」,離開碼 2 ——
請回報。
萬一連自動還原都沒做成,畫面會叫你立刻自己跑一次
--restore |
「備份跟現在的 FEENG.LOC 長度不一樣,不敢拿它覆蓋」 |
這是刻意擋的,不是壞掉。本工具只做同長度的原地覆寫,
自己留下的備份一定跟正本一樣大;不一樣代表備份那次被中斷,
或這個 .LOC 後來被別的東西換掉了
(例如做過中文化那一課)。
請改用你自己那一份完整備份 |
| 選單上變成空白或方框 | 你的遊戲沒有中文字型。
--restore 還原,先做中文化那一課 |
這一課到哪裡為止(本站沒驗的)
- 本站沒有在遊戲裡看過改完的選單。 檔案層面全部驗過(大小不變、字串總數不變、改的複驗全對、 6,349 條沒點名的逐字相同、還原後逐位元組相同), 但「遊戲跑起來長怎樣」需要真的開遊戲。
- 沒有試過把字串改長。本工具刻意不做 —— 要改長就得重算 6,352 條的位移,本站認為那個風險不值得。
- 同一條字串可能在很多地方出現。 例如編號 270「Main Menu」在主選單版面裡被引用 10 次; 改一次就全部都變。本站沒有逐一追過每條字串被哪些畫面用到。
- 「改主選單有風險」那句話仍然成立 —— 但不是這一課的風險。 危險的是關掉選單元素(可能整個模式進不去), 這一課只換字,選單結構完全沒動。要關元素請看 介面實例集,那裡有警告。
這支腳本在做什麼
它做的事分成兩半,中間靠一個數字接起來。
前半:打開封裝檔、找出版面檔、解壓,一行一行掃過去,
把每一行用到的字串編號抓出來。
後半:拿那個編號到 .LOC 字串表裡找到對應的那一格,
算出它在檔案裡的起訖位置,原地覆寫,
新文字後面補 NUL 補到原本的長度。
因為長度沒變,位移表一個位元組都不用動,你沒點名的字串也就保證逐字相同。
整支零相依,--selftest 會自己造一個最小的字串表來測,不碰任何遊戲檔。
| 哪一段 | 做什麼 | 為什麼要有它 |
|---|---|---|
menu_entries() |
打開封裝檔、找出版面檔、解壓, 一行一行掃出「哪些行帶字串編號」,同一個編號只列一次。 | 這是 --list 那張表的來源。
它不靠指令型別的白名單:主選單裡 31 個
TB 對 10 個 TX,第一版只抓 TX,
真正想改的字一個都沒出現,而畫面上還印得很像對。 |
list_entries() |
讀封裝檔(BIGF)的目錄,
找出 fes_mainmenu.fel 在檔案裡的哪一段。 |
版面檔包在封裝檔裡,不是散在資料夾。 目錄項目沒有固定長度(4 個位元組的位移、4 個位元組的長度, 接一個以 NUL 結尾的檔名),只能一項一項往前走。 |
qfs_decompress() |
開頭兩個位元組是 10 FB 就解壓,
不是就原樣回傳。 |
同一個封裝檔裡有壓過的也有沒壓的,
呼叫端只看那兩個位元組。它的長度欄位是 big-endian,
而後面 .LOC 裡全部是 little-endian,看錯就整個解不出來。 |
string_id_of() |
在一行的參數裡找到結尾是 .ffn 的欄位
(那是字型檔),取它後面那一個數字,就是字串編號。 |
帶文字的指令有好幾種,每一種前面的欄位數不一樣, 「數到第幾欄」一定會錯。字型檔一定緊接在字串編號前面, 這個關係比欄位位置穩,而且不會因為遇到沒見過的指令就漏掉。 |
loc_slots() |
解開 .LOC,算出每一條字串
在整個檔案裡的起訖位元組與現在的文字。 |
位移表裡的數字是相對於 LOCL 區的開頭,
不是相對於檔案開頭,這裡已經換算好。
原地覆寫要的就是這兩個位置。讀壞了一律回空的、不丟例外,
因為「四個候選檔名裡有幾個不存在」是正常情況。 |
find_locs_for()(以及只回第一張的 find_loc_for()) |
決定某個編號住在四個候選字串表的哪幾個裡面。--set 用前者:
每一張含這個編號的表都改。 |
EA 官方繁體中文版沒有 ENG 那一對,
中文放在 JPN 那一對裡。只找 ENG 會回 0 筆而且不報錯,
那正是本站最怕的靜默失敗。
2026-10-29 訂正:這一格原本寫「先找到的先用,同一個編號兩張表都有時,只會改到排在前面的那一張」,
程式碼裡也註解「兩張表的編號各自獨立」。兩句都是錯的:兩張表有 1,310 個共用編號
(見文字表 .LOC),只改第一張,選單是新字、比賽中還是舊字。
現在 --set 預設兩張一起改;find_loc_for() 只剩 --list 顯示現在的文字用。 |
cmd_find() |
--find 字:不限哪個畫面,在四個字串表裡找含這個字的字串,
列出編號、在哪幾張表、各張表現在寫什麼、還能放幾個字。唯讀。 |
--list 只列「某個版面檔用到的字」,找不到不在版面檔裡的字,
例如球隊名。知道畫面上寫什麼、不知道編號的人靠這一條。
隱藏的傳奇隊與英雄隊就是這樣改的,見改傳奇隊與英雄隊的名字。 |
budget_chars() |
算這一格放得下幾個字: 槽位的位元組數除以 2,再扣掉結尾固定的 2 個 NUL 字元。 | 要讓你在動手之前就知道放不放得下,
而不是一條一條試。--list 那一欄「可用字數」就是它算的。 |
write_slots() |
原地覆寫:新文字編成 UTF-16LE, 後面補 NUL 補到原本的槽位長度。太長就直接拒絕。 | 這是整支的核心承諾。
長度不變,位移表就一個位元組都不用動;
要把字串改長,就得重算它後面每一條的位移,
6,352 條裡錯一個,遊戲讀到的就是別條字串的中間。
最後那道 if len(buf) != len(data): raise 就是在守這個承諾;
刻意不用 assert,因為 python -O 會把 assert 整行拿掉。 |
cmd_set() |
預覽或寫入。先歸檔、印表、擋掉太長的,
才備份、寫暫存檔、換過去,最後重新讀回來複驗。
暫存檔的名字是隨機的(mkstemp 產生,
開在同一個資料夾裡),不用「正本名字 + .part/.tmp」
這種猜得到的名字。 |
所有會失敗的檢查都排在寫入之前, 失敗時檔案還沒被碰過;而且是整批停住, 不做「能寫幾條算幾條」。複驗刻意從磁碟重讀: 拿記憶體那份比只證明「我算對了」,讀回來才證明「檔案真的變成那樣」。 名字要隨機是有原因的:猜得到的暫存檔名如果已經被人先擺了一條 指向資料夾外面的符號連結,寫下去就會把外面那個檔覆蓋掉。 所以動手之前還會先擋一次 —— 要改的字串表或它的備份本身是符號連結就直接拒絕。 |
cmd_restore() /
_restore_from_backup() |
還原。四道:備份或正本本身是符號連結就直接拒絕、
備份開頭要是 LOCH、
備份跟現在那個檔的長度必須完全相同、
再順著它自己的位移走一次,看走不走得完。
四道都過了才換檔,而且換檔是原子的:
寫一個隨機名字的暫存檔 → 讀回來跟備份比 sha256 →
相同才換上去。 |
備份寫到一半被中斷會留下半截檔,
而半截的備份會在你最需要它的時候把好檔蓋掉
(本站實測過:416,753 位元組的字串表被 52,094 位元組的半截備份蓋掉,
而且印的是成功)。
光看結構擋不乾淨:截掉一大半的走不完位移、會被抓到,
但只少掉最後 417 個位元組的(416,336)結構那幾道全部驗得過。
(2026-09-05 訂正:這一格原本寫「被截斷時,最後一條字串的位移一定會
指到檔案結尾之外」,那句話是錯的 —— 上面那個 416,336 就是反例。)
所以現在多一道「長度必須完全相同」:本工具只做同長度的原地覆寫,
自己留下的備份一定跟正本一樣大,這一條在這一課永遠成立。
長度對不上時會印出兩個長度並拒絕,要你改用自己另外留的那份完整備份。
換檔那一步也重寫過:舊寫法是直接把備份複製回正本,
而複製的第一刀就是把正本清成 0 再一段一段寫回去 ——
中途出事(磁碟滿、Ctrl-C、拔隨身碟)就兩份都沒了。
現在改成先寫暫存檔、比對 sha256 相同了才換上去,
這一步要嘛整個成功、要嘛完全沒發生。
另外多一道「符號連結一律拒絕」:順著連結寫下去改到的是資料夾外面
那個檔,而換檔只會換掉連結本身。 |
_fake_loc() / selftest() |
自己造一個最小的字串表, 把讀、算可用字數、改短、擋太長全部跑一遍。 | 不必有遊戲也驗得動。
道數是程式自己算出來的,跑完會印在最後一行 ——
寫死的數字改了程式就會過期。本站這台跑出來是
53 道檢查,其中 36 道是反向餌;
建不了符號連結的機器(Windows 沒開開發者模式)會
跳過 9 道並印出來說跳過了,那種機器上是 44 道、29 道反向餌
(這組數字是把建連結的那一步換成一定失敗的版本模擬出來的,Windows 上還沒實測)。
餌咬的是:太長要被擋、槽位終點超出檔案結尾(會把檔案撐長)時
「長度不變」那道最後防線要丟錯、長度對不上的備份要被 --restore 擋下來、
暫存檔的名字被人先用符號連結佔住、備份檔或字串表本身是符號連結、
複驗四件事裡「沒點名的字串被寫壞了」要當場停下來、
還原換檔失敗時正本不可以被動到。
正向那幾道則是證明正常流程真的做到承諾(改短之後長度不變、
沒點名的逐字相同、完整的備份還原得回逐位元組相同)——
少了正向,反向餌有可能只是「因為整支都壞了」才過的。
會寫成這樣是因為「防線壞了而測試照樣全綠」是真的發生過的事。
以上是在本頁附的這一份 mvp_menu_text.py 上跑出來的。 |
做不到的事全部寫在腳本開頭:不能把字串改長、不會幫你裝中文字型、 也不會改字的位置、大小與顏色(那些在版面檔裡,是別課的事)。 還有一件要記得:一條字串可能被很多畫面共用,改一次就全部都變。
完整原始碼
mvp_menu_text.py(零相依)。
.LOC 的解析整段沿用本站
介面萬用工具。
跑 --selftest 會自己造一個最小的字串表來測,
不碰任何遊戲檔。道數是程式自己算的,會印在最後一行:
本站這台是 53 道(36 道反向餌),
建不了符號連結的機器跳過 9 道之後是 44 道(29 道反向餌,模擬出來的,Windows 上還沒實測)。
「沒點名的不能被動到」在正向那一段,不是餌。
python3 -I mvp_menu_text.py --selftest
展開 / 收合完整原始碼(2169 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_menu_text.py
改 EA MVP Baseball 2005 選單上的文字(預設是主選單)。
看有哪些字可以改(唯讀)
python3 -I mvp_menu_text.py "你的遊戲資料夾" --list
找某個字在哪一條(唯讀;中英文都找,大小寫不分,四個字串表一起找)
python3 -I mvp_menu_text.py "你的遊戲資料夾" --find Legends
預覽(不會動到遊戲)
python3 -I mvp_menu_text.py "你的遊戲資料夾" --set 12=馬上開打
真的改
python3 -I mvp_menu_text.py "你的遊戲資料夾" --set 12=馬上開打 --apply
還原
python3 -I mvp_menu_text.py "你的遊戲資料夾" --restore
自我測試(不碰任何遊戲檔)
python3 -I mvp_menu_text.py --selftest
原理:版面檔(.fel)裡的 TX 元素帶一個「字串編號」,真正的文字放在
data/ 底下的 .LOC 字串表(UTF-16LE)。所以改選單文字**不必動版面檔**,
只要改字串表裡那一條 —— 這比動版面檔安全得多。
⚠️ 只能改成**同長或更短**的文字。本工具是原地覆寫:
檔案長度不變、位移表一個位元組都不動、你沒點名的字串保證逐字相同。
在**原廠英文**的字串表上,每個槽位的可用字數剛好等於原字串長度。
本站量了兩份:剛安裝好的原版 FEENG.LOC(415,528 位元組)與本站測試機
那一份(416,753 位元組),兩份都是 6,352 條全部以 2 個 NUL 字元收尾、
可用字數等於原字數,零例外。中文字少,英文換中文通常綽綽有餘。
⚠️ 這個「零例外」只對那兩份原廠英文表成立,不是 .LOC 這個格式的性質。
本站那份 2009 年台灣模組的中文 FEENG.LOC(219,421 位元組)有 6,436 條,
其中 145 條不是「剛好」:114 條空間反而更大,30 條的可用字數比原字數
少一個字(那幾條連原本的字都寫不回去,工具會擋下來、不會寫壞檔),
還有 1 條算出來的槽位長度是負的。所以預算一律看 --list 印的「可用字數」,
不要自己拿原字數當預算。
⚠️ 改成中文之前,你的遊戲要先有中文字型,否則畫面上會變成空白或方框。
看本站「把官方中文版的中文搬進英文版」那一課。
它做什麼(一句話)
把選單上顯示的那一條字,直接在字串表裡覆寫成你要的字。
輸入
· 遊戲資料夾(裡面要看得到 data 這個子資料夾)
· --set 編號=新文字,可以寫很多次。編號用 --list 或 --find 查
⚠️ 2026-10-29 起:**同一個編號在選單表(FEENG / FEJPN)和比賽中的表
(IGENG / IGJPN)都有時,預設兩張一起改**。以前(2026-09-24 以前的版本)
只改到排在前面的選單表,比賽中還是舊字。只想改一張,編號前面加
FE: 或 IG:,例如 --set IG:18100=日本;要回到舊行為(同編號只改第一張)
加 --first-only
· 版面檔預設是主選單 fes_mainmenu.fel(在 data/frontend/frontend.big 裡),
想看別的畫面就用 --fel 與 --big 換掉
輸出
--list 印出這個版面檔用到哪些字串、現在寫什麼、還能放幾個字
--find 字 不限哪個畫面,在字串表裡找含這個字的字串:編號、在哪幾張表、
各張表現在寫什麼、還能放幾個字(唯讀)
--set 沒加 --apply 只印預覽;加了才寫,而且寫完立刻讀回來複驗
--restore 從 .menutextbak 還原
--selftest 自己造一個最小的字串表來測,不碰任何遊戲檔
安全網在哪
· 預設唯讀:沒有 --apply 就只印表格
· 太長的字在寫入之前就擋掉,不會寫到一半才發現。量「太長」用的是
UTF-16 的編碼單位,跟真正把關的那一關同一把尺;而且加了 --apply 之後
也還會先把每一個字串表的新內容全部算完,一個都沒出事才開始寫第一個檔
· 第一次寫入前自動備份,而且備份是原子的(先寫同一個資料夾裡一個
隨機名字的暫存檔,整份寫完、fsync 落地了才改名成正式的備份檔名)
· 所有暫存檔都用 tempfile.mkstemp 產生,**不用「正本名字 + .part / .tmp」
這種猜得到的名字**:那個名字如果已經是一條指向資料夾外面的符號連結,
寫下去就會把外面那個檔覆蓋掉。正本或備份本身是符號連結時直接拒絕動手
· 寫入走「先寫暫存檔,fsync 之後再 os.replace」,不會出現半截的字串表
· 寫完立刻讀回來複驗四件事:檔案大小、字串總數、改的那幾條、
沒點名的那幾條是不是逐字相同。**四件只要有一件對不上就停**,
而且會當場自動從剛剛那份備份原子還原,把遊戲檔放回動手前的樣子,
離開碼是 2 不是 0。四個數字照樣都印出來給你看
· 一個 --set 同時點到兩個字串表時是**全有或全無**:後面那個檔出了事,
前面已經換好的會用備份放回動手前的樣子,不會留下一半新一半舊。
同編號兩張表一起改的時候,每張表**各自**量「放不放得下」,
任何一張放不下就整批停住,兩張都不動
· 被 Ctrl-C 中斷時會照實說。換名與「已經換過了」的登記綁成一段不會被
切開的區間,所以收尾印的「什麼都沒有動到 / 這些檔已經改好了」跟磁碟上
的狀態一定一致,離開碼 130。已經改好的會把還原指令印給你
· 還原(--restore 與上面那個自動還原)也是原子的:先寫同資料夾的暫存檔、
讀回來跟備份比 sha256,相同了才 os.replace 換上去。**不是** copy2 直接
蓋正本 —— copy2 會先把正本截成 0 再一段一段寫,途中斷掉就沒了
· --restore 只認 .menutextbak,而且還原之前會先驗備份:開頭要是 LOCH、
**長度要跟現在那個檔完全相同**(本工具只做同長度覆寫,所以這一條永遠成立;
半截的備份就是這樣被擋下來的)、內部結構要走得完
做不到的事(先說,免得你白忙)
· 不能把字串改長,只能同長或更短(理由見下面「改字串」那一段)
· 不會幫你裝中文字型。沒有字型的話就算改成功,畫面上也是空白或方框
· 不會改字的位置、大小、顏色(那些在版面檔 .fel 裡,不是這一支的事)
· 一條字串可能被很多畫面共用,改了就全部一起變。
本站沒有逐條追過每個編號被哪些畫面用到
· 本站沒有在遊戲裡看過改完的畫面,驗的是檔案層面
備份副檔名 .menutextbak,只認自己這一個,不會去動別課留下的 .bak。
無外部相依,Python 3.7 以上即可。
授權:MIT(見檔尾完整條款)。本站教學文字另採 CC BY 4.0。
"""
TOOL_DATE = '2026-10-30.2' # 這一版工具的日期(同一天第 N 次改寫加 .N)
#
# ─────────────────────────────────────────────────────────
# 法律與免責(每一支本站腳本都帶著這一段)
#
# · 本工具與 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
# ── 備份的原子性(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 只看開頭,看不出後面少了多少。
# os.replace 到底做過沒有 —— Ctrl-C 的時候要說真話。
# 「什麼都沒有動到」這句話,只有在這兩個清單都是空的時候才可以印。
_WROTE = [] # --apply 換上去的遊戲檔
_RESTORED = [] # 還原(--restore 或複驗沒過時自動)換回去的遊戲檔
_REPLACING = [] # 「正在換名」的中間態:進去之前登記,登記完成之後拿掉
class _NoInterrupt(object):
"""把「os.replace + 登記」包成一段不會被 Ctrl-C 切開的區間。
⚠️ 這是 2026-09-06 補的,補的是一個會讓程式**說謊**的窗口:
os.replace 已經把遊戲檔換成新的了,但 _WROTE.append 還沒跑,
Ctrl-C 剛好落在這兩行之間 —— 收尾看到空的清單,就會印
「什麼都沒有動到」。檔案其實已經換了。時間窗很窄,但它是真的。
做法是這段期間先把 SIGINT 記下來不處理,離開這段之後再照常丟出
KeyboardInterrupt。所以收尾看到的登記,跟磁碟上的狀態一定一致。
⚠️ signal.signal 只能在主執行緒裝。裝不上(非主執行緒、某些嵌入環境)
就退回原本的行為 —— 不會更糟,而且還有 _REPLACING 那個中間態兜著。
"""
def __enter__(self):
self._pending = False
self._old = None
try:
self._old = signal.signal(signal.SIGINT, self._remember)
except (ValueError, OSError, AttributeError):
self._old = None
return self
def _remember(self, signum, frame):
self._pending = True
def __exit__(self, exc_type, exc, tb):
if self._old is not None:
try:
signal.signal(signal.SIGINT, self._old)
except (ValueError, OSError):
pass
if self._pending and exc_type is None:
raise KeyboardInterrupt
return False
def _refuse_if_symlink(path, what):
"""是符號連結就拒絕動手,不要順著它走到資料夾外面去。
⚠️ **不可以**用 os.path.exists() / Path.exists() 判斷:那兩個會跟著連結
去看目標,目標不存在(dangling)時回 False,整道檢查等於沒有。
看連結本身要用 os.path.islink / os.path.lexists / os.lstat。
"""
if os.path.islink(path):
raise DataError(
'%s是一條符號連結:%s\n'
' 順著它寫下去會改到資料夾外面的檔,所以不做。\n'
' 請把它換成真正的檔案(或先自己把它搬開)再跑一次。' % (what, path))
def _atomic_copy(src, dst):
"""備份要嘛完整、要嘛不存在 —— 中間狀態不會留在 dst 這個名字上。
⚠️ 本站有些腳本用 pathlib.Path 存路徑,有些用字串。
2026-08-29 第一版寫成 dst + '.part',在 Path 上直接 TypeError,
等於所有備份都失敗 —— 而且「半截備份被擋下來」那個測試照樣是綠的。
是陰性對照(先證明正常流程真的會產生備份)抓到的。
⚠️ 2026-09-05 第二次改:暫存檔的名字**不可以**是「dst + '.part'」。
那個名字猜得到 —— 它如果已經是一條指向資料夾外面的符號連結,
shutil.copy2 會**跟著連結**把外面那個檔截成 0 再寫進去;
後面的 os.replace 只換掉連結本身,外面那個檔早就沒了。
改用 tempfile.mkstemp:名字是隨機的,而且是用 O_EXCL 開的,佔不住。
"""
# 先寫進同一個資料夾裡的暫存檔,整份寫完、fsync 落地了才改名成正式的
# 備份檔名。中途被中斷時壞掉的是那個暫存檔,正式那個名字要嘛還沒出現、
# 要嘛就是完整的。
src, dst = os.fspath(src), os.fspath(dst)
_refuse_if_symlink(dst, '備份檔')
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dst) or '.',
prefix='.' + os.path.basename(dst) + '.part-')
try:
with os.fdopen(fd, 'wb') as fo:
with open(src, 'rb') as fi:
shutil.copyfileobj(fi, fo)
fo.flush()
os.fsync(fo.fileno())
shutil.copystat(src, tmp) # 權限與時間戳跟著來源走(等同 copy2)
os.replace(tmp, dst) # os.replace 是原子的
except BaseException:
try:
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 量到的為準,這裡不寫會過期的數字)
3. LOCH(語系檔):檔頭指到的 LOCL 要在檔內、位移表要在檔內、
文字區宣告的長度要在檔內,最後一條字串的位移也要在檔內。
⚠️ 這幾道**不足以**認出「只少掉最後那幾百個位元組」的半截備份:
本站測試機那份 FEENG.LOC 是 416,753 bytes,最後一條字串在 415,762、
文字區宣告到 415,528,所以截在 415,762 之後都驗得過。
那個窗口由呼叫端 cmd_restore 的「備份與正本長度必須相同」補上
(這一支只做同長度的原地覆寫,所以那一條在這裡永遠成立)
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 道:BIGF 的檔頭第 4-8 個位元組寫著它自己應該有多大。
# ⚠️ 這一支動的是 .LOC 不是 .big,所以這一段在本檔實際上跑不到;
# 留著是因為這整段還原防線在本站多支腳本之間是同一份,要改就得一起改。
if len(head) == 8 and head[:4] == b'BIGF':
le = struct.unpack('<I', head[4:8])[0]
be = struct.unpack('>I', head[4:8])[0]
if le != n and be != n:
_stop('檔頭說它應該是 %d bytes(或 %d),實際只有 %d bytes。' % (le, be, n))
return _do_copy(bak, dst)
# 第 3 道:語系檔。**這一支真正會走到的就是這一道。**
# 檢查的邏輯是「順著它自己的位移走一次,看走不走得完」:
# LOCH 檔頭指到的 LOCL 要在檔案內,文字區宣告的長度與最後一條字串的位移
# 也都要在檔案內。截得夠多時這幾道就會亮,但截得很少時不會 ——
# 真正把那個窗口關掉的是 cmd_restore 的長度相同檢查,見上面 docstring。
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)
# 文字區自己宣告有多長,那一整塊也要在檔案裡。
# 本站這台機器上找得到的 19 個 .LOC(剛安裝好的英文版與繁中版、
# 幾個社群模組、本站測試機那一份)全部滿足 L + lsize ≤ 檔案長度:
# 其中 8 個剛好相等,另外 11 個檔尾多出 50 到 1,225 個位元組,
# 所以只能比「不超過」,不可以比「相等」。
lsize = struct.unpack('<I', d[L + 4:L + 8])[0]
if L + lsize > n:
_stop('語系檔的文字區宣告到位移 %d,超出檔案結尾(%d bytes)。'
% (L + lsize, n))
last = struct.unpack('<I', d[L + 16 + (lcnt - 1) * 4:L + 20 + (lcnt - 1) * 4])[0]
if L + last >= n:
_stop('語系檔最後一條字串在位移 %d,超出檔案結尾(%d bytes)。'
% (L + last, n))
except SystemExit:
raise
except (struct.error, IndexError):
_stop('讀不出語系檔的結構,它壞了。')
if len(head) >= 2 and head[:2] == b'MZ':
try:
d = open(bak, 'rb').read()
pe = struct.unpack('<I', d[0x3C:0x40])[0]
if pe + 24 > n or d[pe:pe + 4] != b'PE\x00\x00':
_stop('執行檔的 PE 檔頭不在它該在的地方,檔案不完整。')
nsec = struct.unpack('<H', d[pe + 6:pe + 8])[0]
optsz = struct.unpack('<H', d[pe + 20:pe + 22])[0]
sec = pe + 24 + optsz
end = 0
for i in range(nsec):
o = sec + i * 40
if o + 40 > n:
_stop('執行檔的節區表被截斷了(宣告 %d 個節區)。' % nsec)
raw_sz, raw_off = struct.unpack('<II', d[o + 16:o + 24])
end = max(end, raw_off + raw_sz)
if end > n:
_stop('執行檔的節區指到 %d bytes,實際只有 %d bytes。' % (end, n))
except SystemExit:
raise
except (struct.error, IndexError):
_stop('讀不出執行檔的結構,它壞了。')
# 第 5 道:通用地板。這一支是原地覆寫,改完的 .LOC 跟原本一樣大,
# 所以「備份不到正本的一半」只有一種解釋:備份那次出事了。
# 通用地板 —— 非 BIGF 走到這裡
if os.path.exists(dst):
live = os.path.getsize(dst)
if live > 0 and n * 2 < live:
_stop('備份只有 %d bytes,而要被蓋掉的那個檔有 %d bytes ——'
'差太多了(不到一半)。' % (n, live))
return _do_copy(bak, dst)
def _do_copy(bak, dst):
"""真正的還原動作,而且是原子的。
獨立成一支,是為了讓上面每一道檢查都以 return _do_copy(...) 收尾:
檢查沒過就永遠走不到這裡,不會有「檢查完忘了 return」那種漏網。
⚠️ 2026-09-05 把 shutil.copy2(bak, dst) 換掉了。copy2 的動作是
「先把 dst 截成 0 bytes,再一段一段寫進去」:途中按了 Ctrl-C、
磁碟滿了、外接碟被拔掉、程式崩潰,玩家的遊戲檔就停在半截或 0 ——
而這支指令的名字叫「還原」。**救火的動作本身不可以是第二把火。**
上面那五道只驗得出「備份是不是壞的」,驗不到「複製到一半斷掉」。
現在的順序:
1. 正本與備份是符號連結就拒絕(不順著它改到資料夾外面的檔)
2. 寫進**同一個資料夾**裡一個隨機名字的暫存檔(同一個檔案系統,
os.replace 才是原子的),flush + fsync 落地
3. 權限沿用「要被換掉的那個正本」,不是備份的
4. **把暫存檔讀回來跟備份比 sha256**,相同才算數
5. os.replace 換上去 —— 這一步要嘛整個成功、要嘛沒發生
任何一步失敗就把暫存檔刪掉,正本一個位元組都沒被碰過。
"""
bak, dst = os.fspath(bak), os.fspath(dst)
_refuse_if_symlink(bak, '備份檔')
_refuse_if_symlink(dst, '要還原的那個遊戲檔')
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dst) or '.',
prefix='.' + os.path.basename(dst) + '.restore-')
try:
want = hashlib.sha256()
with os.fdopen(fd, 'wb') as fo:
with open(bak, 'rb') as fi:
while True:
chunk = fi.read(1 << 20)
if not chunk:
break
want.update(chunk)
fo.write(chunk)
fo.flush()
os.fsync(fo.fileno())
# 權限沿用「要被換掉的那個正本」;正本不在了(整個檔被刪掉才來還原)
# 就退回用備份的權限 —— 不補這一段的話,還原出來的檔會是 mkstemp 的
# 0600,遊戲讀不讀得到要看它跑在誰的身分底下。
try:
shutil.copymode(dst if os.path.isfile(dst) else bak, tmp)
except OSError:
pass
# 讀回來驗:證明「檔案真的變成那樣」,不是「我算對了」。
got, n = hashlib.sha256(), 0
with open(tmp, 'rb') as f2:
while True:
chunk = f2.read(1 << 20)
if not chunk:
break
n += len(chunk)
got.update(chunk)
if n != os.path.getsize(bak) or got.digest() != want.digest():
raise DataError(
'寫出來的暫存檔跟備份對不上(磁碟可能有問題),所以沒有換上去。\n'
' %s 一個位元組都沒有被動到。' % dst)
# 換名與登記綁在一起:Ctrl-C 不可以卡在這兩行中間,
# 不然收尾會照舊狀態說「什麼都沒有動到」。
_REPLACING.append(dst)
try:
with _NoInterrupt():
os.replace(tmp, dst)
_RESTORED.append(dst)
_REPLACING.remove(dst)
except OSError:
# os.replace 是原子的:它丟例外就是沒換成,中間態要收掉。
if dst in _REPLACING:
_REPLACING.remove(dst)
raise
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except Exception:
pass
# 兩個上限都是防呆:檔案自己宣告的數字不可以無限相信。一個被動過手腳的檔
# 可以宣稱「解壓後有 4 GB」或「目錄有一億項」,照著配置記憶體就當場把機器吃垮。
MAX_UNCOMPRESSED = 64 * 1024 * 1024 # QFS 解壓後的位元組上限
MAX_ENTRIES = 100000 # 封裝檔目錄項目數上限
VISIBILITY_FIELD = 1 # 元件名稱之後的第 1 個參數 = 顯示開關
# ⚠️ 下面這個 DEFAULT_BIG 在檔案後半段被同名的另一個蓋掉(那個指向 frontend.big),
# 真正生效的是後面那一個。這裡照原樣留著,只加註記,不動程式碼。
DEFAULT_BIG = os.path.join('data', 'frontend', 'ingame.big')
# 可以考慮動的指令。真正決定動不動得了的是 visibility_of:這張表過了,
# 還要那一行的第 1 個參數真的讀得到 0 或 1。本站測試機的 data/frontend/
# (六個封裝檔、332 個版面檔、141,767 行,其中 141,765 行認得出指令)
# 量到:VR / LS / LF / END 冒號後面只有一格,沒有第 1 個參數;
# TS 那一格放空白,SF 放版面檔檔名;
# KA / VS / SC 那一格是 0 或 1,但本站沒有獨立證據證明它就是顯示開關,
# 所以不放行。表裡的 SE 與 TE 同樣沒有那一欄,是這張表列錯了,
# 被 visibility_of 擋著。
# ⚠️ 這張表跟下面的 fel_lines / visibility_of 是從本站「介面萬用工具」沿用過來的
# 共用零件。這一課只換字、不碰顯示開關,所以本檔沒有用到它們。
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)。
⚠️ 這一課只讀版面檔、不寫回封裝檔,所以本檔沒有呼叫這一支。
它跟下面的 qfs_compress_literal 一樣,是沿用共用零件時一起帶進來的。
BIGF 檔頭 +0x04 起的四個位元組是「檔案總大小」。哪一種順序不是這個格式
天生的性質,而是看你手上這一份 data 資料夾被疊過什麼。本站以 BIGF 檔頭
認過三份 data 資料夾:剛安裝好的原版英文版 207 個封裝檔、剛安裝好的原版
繁體中文版 205 個,這兩份沒有一個是 big-endian;只有本站測試機那份疊過
模組的 data 資料夾出現 big-endian,384 個封裝檔裡有 10 個,而且只落在
這 7 個檔名上(models.big / frontend/portrait.big / pnamedat.big /
pnamehdr.big / coornite.big / dodgnite.big / wrignite.big;測試機有兩個
球場資料夾,球場那三個檔名各出現兩次,所以檔名 7 個而檔案 10 個)。
不可以照檔案大小猜:兩份原版裡最大的封裝檔就是 models.big
(172,992,803 bytes),它是 little-endian。反過來「被換過就會變 big-endian」
也不成立:測試機上大小跟原版不同的 little-endian 封裝檔還有 135 個。
寫回去時必須沿用原檔那一種,不然會寫進跟原檔不同的位元組。
兩種都對不上時退回 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):
"""QFS(社群也叫它 RefPack)解壓。這一課拿它解版面檔 .fel。
檔頭:第 0 個位元組是旗標,第 1 個固定 0xFB,這就是「10 FB」的由來。
旗標的 bit0 決定「解壓後有多大」寫在哪裡:
bit0 = 1 大小在 +0x06 起的 4 個位元組,壓縮資料從 +0x0A 開始
bit0 = 0 大小在 +0x02 起的 3 個位元組,壓縮資料從 +0x05 開始
這兩個長度欄位都是 big-endian,跟 BIGF 目錄一致,
但跟後面 .LOC 裡那些欄位相反(那邊全是 little-endian)。看錯就整個解不出來。
傳進來的不是 QFS 就原樣回傳。同一個封裝檔裡有壓過的也有沒壓的,
呼叫端只看開頭兩個位元組是不是 10 FB,其餘交給這裡。
"""
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):
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()
# 接下來一路讀「控制位元組」。看第一個位元組落在哪個值域就知道這是哪一種指令:
# 0x00-0x7F 兩個位元組一組:抄 0-3 個原文,再往回 1-1024 複製 3-10 個
# 0x80-0xBF 三個位元組一組:抄 0-3 個原文,再往回 1-16384 複製 4-67 個
# 0xC0-0xDF 四個位元組一組:抄 0-3 個原文,再往回 1-131072 複製 5-1028 個
# 0xE0-0xFB 只抄原文,一次 4 到 112 個(一定是 4 的倍數)
# 0xFC-0xFF 結束,順便抄最後 0-3 個原文
# 「往回複製」的距離是相對於「已經解出來的尾端」,所以邊解邊長,
# 複製到自己剛剛才寫出去的位元組是正常的:重複的花紋就是這樣壓的。
while pos < end:
b0 = data[pos]
if b0 >= 0xFC:
n = b0 & 0x03; pos += 1
out += data[pos:pos + n]; break
if b0 >= 0xE0:
n = ((b0 & 0x1F) << 2) + 4; pos += 1
out += data[pos:pos + n]; pos += n; continue
if b0 >= 0xC0:
b1, b2, b3 = data[pos + 1], data[pos + 2], data[pos + 3]; pos += 4
n = b0 & 0x03
length = ((b0 & 0x0C) << 6) + b3 + 5
offset = ((b0 & 0x10) << 12) + (b1 << 8) + b2 + 1
elif b0 >= 0x80:
b1, b2 = data[pos + 1], data[pos + 2]; pos += 3
n = (b1 >> 6) & 0x03
length = (b0 & 0x3F) + 4
offset = ((b1 & 0x3F) << 8) + b2 + 1
else:
b1 = data[pos + 1]; pos += 2
n = b0 & 0x03
length = ((b0 & 0x1C) >> 2) + 3
offset = ((b0 & 0x60) << 3) + b1 + 1
out += data[pos:pos + n]; pos += n
copy_back(offset, length)
return bytes(out[:size])
def qfs_compress_literal(data):
"""純 literal 編碼:不做字串比對,瞬間完成,格式一樣合法。
⚠️ 本檔沒有呼叫這一支(這一課不寫回封裝檔,只改字串表)。
同樣是沿用共用零件時一起帶進來的。
代價是檔案略大,但因為我們是「接到檔尾」,大一點沒有影響。
"""
n = len(data)
# 檔頭 5 個位元組:0x10 是旗標(bit0 = 0,代表用短的那種長度欄位),
# 0xFB 是 QFS 的招牌,後面 3 個位元組是解壓後大小,big-endian。
out = bytearray([0x10, 0xFB, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF])
# 0xE0 那一族一次只能抄 4 的倍數、最多 112 個,
# 所以先把尾巴不足 4 個的那幾個位元組切開,留給最後的結束指令帶走。
tail = n % 4
body = n - tail
pos = 0
while pos < body:
chunk = min(112, body - pos)
out.append(0xE0 | ((chunk - 4) // 4))
out += data[pos:pos + chunk]
pos += chunk
# 0xFC 那一族是結束記號,低 2 個位元順便帶走最後不足 4 個的那幾個位元組。
out.append(0xFC | tail)
out += data[pos:]
return bytes(out)
# ─────────────────────────────────────────────────────────
# BIG 目錄
# ─────────────────────────────────────────────────────────
def list_entries(data):
"""回傳 [(名稱, TOC 欄位位置, 資料 offset, 資料長度), ...]
BIGF 檔頭 16 個位元組:0-4 招牌 BIGF、4-8 檔案總大小、
8-12 目錄項目數、12-16 目錄區大小。除了「檔案總大小」那一欄之外
全部是 big-endian(那一欄兩種順序都遇得到,見 size_field_order)。
目錄項目沒有固定長度:每一項是 4 個位元組的 offset、4 個位元組的長度,
接一個以 NUL 結尾的檔名。所以只能一項一項往前走,不能用乘法跳到第 n 項。
「TOC 欄位位置」是那 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
for i in range(count):
field = pos
if pos + 8 > len(data):
raise DataError('目錄在第 %d 項處被截斷' % (i + 1))
offset, size = struct.unpack('>II', data[pos:pos + 8])
pos += 8
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)
版面檔一行長這樣:`指令:名稱,參數,參數,…`,縮排代表群組的層級。
(共用零件,本檔沒有呼叫它。這一課用的是 menu_entries 裡那條
自己寫的正規表示式,只挑抓得出字串編號的行。)
"""
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。
(共用零件,本檔沒有呼叫它。這一課只換字,不動任何顯示開關。
關掉選單元素是另一課的事,而且風險高得多。)
"""
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,不中斷主流程。
位移的算法(全部 little-endian):
LOCH 檔頭固定 20 個位元組,LOCI 緊接在後面,所以 LOCI 從 20 開始。
LOCI 的 +4 是它自己的大小、+8 是索引筆數,索引項從檔案位移 32 起,
每項 4 個位元組:高 16 個位元是「第幾條文字」,低 16 個位元是字串編號
(版面檔裡寫的就是這個編號)。
LOCL 從 20 + LOCI 大小 開始,+4 是它自己的大小、+12 是文字條數,
+16 起是位移表。位移表裡的數字是**相對於 LOCL 開頭**,不是相對於檔案開頭。
⚠️ 這一支跟下面的 loc_slots 幾乎一樣,差別是 loc_slots 連「這條字串在
檔案裡的起迄位置」一起回傳,因為要原地覆寫。本檔只用 loc_slots;
load_loc / load_all_loc 是沿用共用零件時一起帶進來的,沒有被呼叫。
"""
try:
d = open(path, 'rb').read()
except OSError:
return {}
if len(d) < 32 or d[:4] != b'LOCH':
return {}
try:
loci_size = struct.unpack('<I', d[24:28])[0]
icnt = struct.unpack('<I', d[28:32])[0]
ents = [struct.unpack('<I', d[32 + i * 4:36 + i * 4])[0] for i in range(icnt)]
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]
offs = [struct.unpack('<I', d[L + 16 + i * 4:L + 20 + i * 4])[0] for i in range(lcnt)]
def text(i):
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 {}
# 四個候選檔名。FE 是前端(選單),IG 是比賽中。
# ⚠️ 2026-10-29 訂正:這裡原本寫「兩張表的編號各自獨立」,是錯的。兩張表用的是**同一組編號**:
# 剛安裝好的原版英文 FEENG ∩ IGENG 有 1,310 個相同編號,其中文字 1,310 / 1,310 逐字相同;
# 原版中文 FEJPN ∩ IGJPN 有 1,389 個,同樣全部逐字相同。
# (社群中文化版 + 2023 名冊的那份〔FEENG 219,421 / IGENG 42,983 位元組〕被模組改過:
# 1,389 個共同編號裡 90 個文字不同,都是模組改的。)
# 所以改一條字串要兩張表一起改,選單與比賽中才會一致 —— 傳奇隊 / 英雄隊(編號 18099~18104)
# 就是這樣,隊名在選單表與比賽中的表各有一份。
# 字串表的檔名跟語言版本綁在一起。
# ⚠️ 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 這類指令的參數裡,字型檔後面那個數字就是字串編號。
做法是掃過每一個參數,遇到結尾是 .ffn 的(那是字型檔),
就看它後面那一個是不是純數字。是,那就是字串編號。
為什麼用「找 .ffn」而不是「數到第幾欄」:帶文字的指令有好幾種
(TX / TB / TL / TE …),每一種前面的欄位數不一樣,數欄位一定會錯。
字型檔一定緊接在字串編號前面,這個關係比欄位位置穩。
"""
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
# ─────────────────────────────────────────────────────────
# 改字串:為什麼只准「同長或更短」
# ─────────────────────────────────────────────────────────
# .LOC 的結構是「位移表 + 一整塊連續文字」。想把某一條字串改長,
# 就得把它後面**每一條**的位移全部往後推,並重算整個 LOCL 區的長度 ——
# 6,352 條裡改錯任何一個位移,遊戲讀到的就是別條字串的中間。
#
# 所以這支工具走另一條路:**原地覆寫,長度不變。**
# · 檔案總長度不變
# · 位移表一個位元組都不動
# · 你沒點名的 6,351 條保證逐位元組相同
# 代價是新文字不能比原本長。在原廠英文的字串表上,每個槽位的可用字數
# **剛好等於原字串長度**:本站量了兩份 FEENG.LOC(剛安裝好的原版
# 415,528 位元組、本站測試機 416,753 位元組),兩份都是 6,352 條全部以
# 2 個 NUL 字元收尾、可用字數等於原字數,零例外。所以規則很單純:
#
# 新文字的字數 ≤ 原文字的字數
#
# ⚠️ 這對「英文改中文」剛好夠用 —— 中文字少。
# Play Now(8 字)換成「馬上開打」(4 字)綽綽有餘。
# 反過來想把中文改成長英文就會被擋下來,那是刻意的。
#
# ⚠️ 「零例外」只對那兩份原廠英文表成立,不是 .LOC 這個格式的性質。
# 只要字串表被人改過就不一定:本站那份 2009 年台灣模組的**英文**
# FEENG.LOC(415,840 位元組)有 5 條不是 2 個 NUL,只是那 5 條空間反而更大;
# 同一包裡的**中文** FEENG.LOC(219,421 位元組)有 6,436 條,
# 其中 6,291 條剛好、114 條空間更大、30 條的可用字數比原字數少一個字
# (「打擊」「捕手」「全壘打」這種),還有 1 條算出來的槽位長度是負的。
# 那 30 條連原本的字都寫不回去,但只會被 write_slots 擋下來,不會寫壞檔。
# 程式不必為此改什麼:budget_chars 本來就是照槽位算的,
# 但你不能拿原字數當預算,要看 --list 印出來的「可用字數」。
# 備份用自己的副檔名,不叫 .bak。別課的腳本也會在 data/ 底下留備份,
# 名字撞在一起會互相覆蓋,而且還原的時候你分不出來是哪一課留的。
BAK_SUFFIX = '.menutextbak'
def loc_slots(path):
"""回傳 (原始位元組, {字串編號: (起, 迄, 文字)})。讀不到回 (None, {})。
「起、迄」是這條字串在**整個檔案**裡的位元組位置(位移表裡是相對於
LOCL 開頭的,這裡已經加上 L 換算好了)。原地覆寫要的就是這兩個數字。
最後一條沒有下一條可以當終點,就用 LOCL 宣告的總長度收尾。
讀壞了一律回 (None, {}) 而不是丟例外:呼叫端會依序試四個候選檔名,
其中一個不是字串表(或根本不存在)是正常情況,不該中斷整個流程。
"""
try:
d = open(path, 'rb').read()
except OSError:
return None, {}
if len(d) < 32 or d[:4] != b'LOCH':
return None, {}
try:
loci_size = struct.unpack('<I', d[24:28])[0]
icnt = struct.unpack('<I', d[28:32])[0]
ents = [struct.unpack('<I', d[32 + i * 4:36 + i * 4])[0] for i in range(icnt)]
L = 20 + loci_size
if d[L:L + 4] != b'LOCL':
return None, {}
lsize = struct.unpack('<I', d[L + 4:L + 8])[0]
lcnt = struct.unpack('<I', d[L + 12:L + 16])[0]
offs = [struct.unpack('<I', d[L + 16 + i * 4:L + 20 + i * 4])[0]
for i in range(lcnt)]
out = {}
for x in ents:
idx, sid = x >> 16, x & 0xFFFF
if idx >= lcnt:
continue
a = L + offs[idx]
b = L + offs[idx + 1] if idx + 1 < lcnt else L + lsize
out[sid] = (a, b, d[a:b].decode('utf-16-le', 'replace').rstrip('\x00'))
return d, out
except (struct.error, IndexError):
return None, {}
def budget_chars(a, b):
"""這個槽位放得下幾個字(扣掉結尾那 2 個 NUL)。
槽位有 b - a 個位元組,UTF-16LE 一個編碼單位 2 個位元組,
再扣掉結尾固定的 2 個 NUL 字元(也就是 4 個位元組)。
⚠️ 這裡數的是 UTF-16 的**編碼單位**,不是 Python 的字數。
常用漢字一個字剛好一個單位,兩邊一樣;但 emoji 這類字要兩個單位,
Python 的 len() 只算一個。真正把關的是 write_slots(它算位元組),
所以 cmd_set 的預覽也要用同一把尺量(它算 len(text.encode('utf-16-le'))//2),
不可以用 len(text) —— 2026-09-05 之前用的就是 len(text),於是那種字
「預覽說放得下、寫入時才被擋」,而 --set 橫跨兩張字串表時
第一張已經寫進去了才在第二張中止。
"""
return max(0, (b - a) // 2 - 2)
def write_slots(data, slots, changes):
"""原地覆寫。changes = {字串編號: 新文字}。回傳新的位元組。
每一條都寫成「新文字 + 補滿 NUL 到原槽位長度」,所以長度一定不變。
"""
# 逐條在原地覆寫:新文字編成 UTF-16LE,後面用 NUL 補到原本的槽位長度。
# 補 NUL 不只是為了湊長度,字串本身也要靠 NUL 收尾,
# 所以上面留了 4 個位元組(2 個 NUL 字元)的餘裕,那是硬性的。
buf = bytearray(data)
for sid, text in changes.items():
a, b, _old = slots[sid]
enc = text.encode('utf-16-le')
room = b - a
if len(enc) + 4 > room:
raise DataError('編號 %d 放不下:新文字要 %d 個位元組,槽位只有 %d '
'(還要留 4 個給結尾)。' % (sid, len(enc), room))
buf[a:b] = enc + b'\x00' * (room - len(enc))
# 這一行是這支工具的核心承諾:長度沒變,所以位移表一個位元組都不用動。
# 承諾一旦破了就是災難級的錯,寧可當場中止,不要寫出去。
# 不用 assert:python -O 會把 assert 整行拿掉,這道防線就憑空消失了。
if len(buf) != len(data):
raise DataError('內部錯誤:長度變了,一個位元組都沒寫')
return bytes(buf)
def table_tag(path_or_name):
"""FEENG.LOC / FEJPN.LOC → 'FE'(選單表);IGENG.LOC / IGJPN.LOC → 'IG'(比賽中的表)。"""
return os.path.basename(path_or_name)[:2].upper()
def find_locs_for(gamedir, sid, tag=None):
"""**所有**含這個編號的 .LOC,照 LOC_NAMES 的順序。回傳 [(路徑, slots), ...]。
tag 是 'FE' 或 'IG' 時只回那一種表。一張都沒有就回空串列。
2026-10-29 起 --set 用這一支:同一個編號在選單表和比賽中的表都有時兩張一起改。
(以前用 find_loc_for,只回第一張 —— 傳奇隊 / 英雄隊的名字因此改完選單是新的、
比賽中還是舊的。)
"""
out = []
for name in LOC_NAMES:
if tag and table_tag(name) != tag:
continue
p = os.path.join(gamedir, 'data', name)
if not os.path.isfile(p):
continue
d, slots = loc_slots(p)
if d is not None and sid in slots:
out.append((p, slots))
return out
def find_loc_for(gamedir, sid):
"""第一個含這個編號的 .LOC。回傳 (路徑, slots) 或 (None, {})。
照 LOC_NAMES 的順序一個一個試,**先找到的先用**(FEENG 排在 IGENG 前面)。
⚠️ 同一個編號兩張表都有時這裡只回前面那一張。--set 不再用這一支
(改用 find_locs_for,兩張一起改);--list 只是要「顯示現在的文字」,
顯示第一張就夠,所以還用它。--first-only 也靠它回到舊行為。
"""
hits = find_locs_for(gamedir, sid)
return hits[0] if hits else (None, {})
# ⚠️ DataError 在檔案上半段已經定義過一次,這裡是第二次,生效的是這一個。
# 兩個都只是 Exception 的空殼,行為完全一樣,所以留著沒有風險。
# 照本站紀律「看到問題寫下來、不順手改」,這裡只加註記。
class DataError(Exception):
pass
# ⚠️ 同上:DEFAULT_BIG 也是第二次定義,真正生效的是這一個(frontend.big)。
# 上面那個指向 ingame.big 的沒有作用。主選單的版面檔在 frontend.big 裡,
# ingame.big 裝的是比賽中的 HUD,不是選單。
DEFAULT_BIG = os.path.join('data', 'frontend', 'frontend.big')
DEFAULT_FEL = 'fes_mainmenu.fel' # 主選單的版面檔
def menu_entries(gamedir, bigrel, felname):
"""回傳 [(行號, 元素名, 字串編號), ...] —— 這個版面檔用到哪些字串。
流程:打開封裝檔 → 在目錄裡找出那個版面檔 → 開頭是 10 FB 就解壓 →
當成文字一行一行掃,把抓得出字串編號的行收起來。
整段唯讀,連暫存檔都不產生。
⚠️ 解出來用 latin-1 解碼,不是 UTF-8。版面檔是純 ASCII 的指令稿,
latin-1 保證每個位元組都對得到一個字元、不會丟例外,
行號也才會跟你用文字編輯器打開時看到的一致。
"""
p = os.path.join(gamedir, bigrel)
if not os.path.isfile(p):
raise DataError('找不到 %s\n 你給的是:%s' % (bigrel, gamedir))
data = open(p, 'rb').read()
hit = None
for name, field, off, size in list_entries(data):
if name.lower() == felname.lower():
hit = (off, size)
break
if hit is None:
raise DataError('%s 裡沒有 %s' % (bigrel, felname))
raw = data[hit[0]:hit[0] + hit[1]]
text = (qfs_decompress(raw) if raw[:2] == b'\x10\xfb' else raw).decode('latin-1')
# ⚠️ 不要只認 TX。選單上的按鈕是 **TB**(文字按鈕),
# 主選單裡 31 個 TB 對 10 個 TX —— 第一版只抓 TX,
# 於是「Play Now」「Game Modes」這些真正想改的字一個都看不到,
# 而且畫面上還印得很像對(10 行 TITLE 全部列出來)。
# 改成「任何兩個大寫字母的指令,只要抓得出字串編號就算」,
# 讓 string_id_of 自己決定 —— 不要靠一份會過期的型別白名單。
# 版面檔一律 CRLF 換行(本站鐵律之一),所以直接切 \r\n,
# 行號從 1 起算,對得上你用文字編輯器打開時看到的行號。
out, seen = [], set()
for n, line in enumerate(text.split('\r\n'), 1):
m = re.match(r'\s*([A-Z]{2}):([A-Za-z0-9_]*),(.*)$', line)
if not m:
continue
sid = string_id_of(m.group(3).split(','))
if sid is None or sid in seen: # 同一條字串被好幾行引用時只列一次
continue
seen.add(sid)
out.append((n, '%s:%s' % (m.group(1), m.group(2)), sid))
return out
def cmd_list(gamedir, bigrel, felname):
"""--list:把這個版面檔用到的字串全部列出來。唯讀。
每一行是:版面檔裡的行號、元素、字串編號、現在的文字、還能放幾個字,
以及它是在哪一個字串表裡找到的。
「可用字數」要在這裡就給,不然使用者只能靠 --set 一條一條試才知道放不放得下。
⚠️ 開頭印的那個數字是「不同的字串有幾條」,不是「帶文字的元素有幾個」。
menu_entries() 已經去重,同一條字串被好幾個元素引用時只列一次。
fes_mainmenu.fel 實測:帶字串編號的元素 41 個(10 個 TX、31 個 TB),
去重後只剩 9 條(編號 12、13、14、270、280、627、8140、8180、16306),
所以 --list 印出來是 9 行。三份 data/frontend/frontend.big 上量到的
都一樣:本站測試機那一份、剛安裝好的全新英文版、剛安裝好的全新中文版。
"""
rows = menu_entries(gamedir, bigrel, felname)
present = loc_files_present(gamedir)
if not present:
raise DataError('找不到任何字串表。本工具會找這四個:%s' % '、'.join(LOC_NAMES))
print(' %s → %s' % (bigrel, felname))
print(' 找到的字串表:%s' % '、'.join(present))
print(' 這個版面檔用到 %d 條不同的字串(同一條字串被好幾個元素引用時只列一次)\n' % len(rows))
print(' %6s %-22s %7s %-28s %s' % ('行號', '元素', '編號', '現在的文字', '可用字數'))
print(' %s' % ('-' * 82))
in_both = False
for n, nm, sid in rows:
hits = find_locs_for(gamedir, sid)
if not hits:
print(' %6d %-22s %7d %-28s %s' % (n, nm, sid, '(四個字串表裡都沒有)', '-'))
continue
path, slots = hits[0]
a, b, txt = slots[sid]
where = '、'.join(os.path.basename(h[0]) for h in hits)
in_both = in_both or len(hits) > 1
print(' %6d %-22s %7d %-28s %d 字(在 %s)'
% (n, nm, sid, txt, budget_chars(a, b), where))
if in_both:
print('\n ※ 「在」後面列了兩張表的編號,--set 預設**兩張一起改**(選單與比賽中才會一致)。')
print(' 上面的「現在的文字」與字數是第一張表的;只想改一張:編號前面加 FE: 或 IG:。')
print('\n 「可用字數」是這條字串**最多**能放幾個字 —— 本工具只做原地覆寫,')
print(' 不會把字串改長(理由見程式開頭)。中文字少,英文換中文通常綽綽有餘。')
print('\n 要改就這樣:')
print(' python3 -I %s "%s" --set 12=馬上開打'
% (os.path.basename(sys.argv[0]), gamedir))
def cmd_find(gamedir, keyword, limit=60):
"""--find:不限哪個畫面,在字串表裡找含這個字的字串。唯讀。回傳找到的編號(排序過)。
--list 只列「某個版面檔用到的字」,找不到不在版面檔裡的字 —— 例如球隊名
(傳奇隊 / 英雄隊的名字是程式依球隊順序去取的,沒有任何版面檔引用)。
這一支補上「知道畫面上寫什麼、不知道編號」的那條路。
· 四個字串表(FEENG / IGENG / FEJPN / IGJPN)有幾個讀到就找幾個
· 大小寫不分(casefold);中文英文都行
· 比對的是**任何一張表**的文字;找到的編號,會把**每一張**有這個編號的表的現況都列出來
(含沒有命中關鍵字的那一張 —— 同一個編號在兩張表裡的字常常不一樣長)
· 最多印 limit 個編號,超過的照實說還有幾個,不會默默截斷
"""
present = loc_files_present(gamedir)
if not present:
raise DataError('找不到任何字串表。本工具會找這四個:%s' % '、'.join(LOC_NAMES))
kw = keyword.strip().casefold()
if not kw:
raise DataError('--find 後面要給一個要找的字。')
tables = {}
for name in present:
d, slots = loc_slots(os.path.join(gamedir, 'data', name))
if d is not None:
tables[name] = slots
if not tables:
raise DataError('四個字串表都讀不出來(檔頭不是 LOCH?)。')
hit_ids = sorted({sid for slots in tables.values()
for sid, (_a, _b, txt) in slots.items() if kw in txt.casefold()})
print(' 找的字:「%s」 查了這幾張字串表:%s' % (keyword, '、'.join(tables)))
if not hit_ids:
print(' 一條都沒有。(中文與英文都找過,大小寫不分。)')
return []
print(' 共 %d 個編號含這個字%s\n' % (len(hit_ids),
'' if len(hit_ids) <= limit else '(只印前 %d 個)' % limit))
print(' %7s %-11s %-30s %s' % ('編號', '字串表', '現在的文字', '可用字數'))
print(' %s' % ('-' * 68))
for sid in hit_ids[:limit]:
first = True
for name, slots in tables.items():
if sid not in slots:
continue
a, b, txt = slots[sid]
print(' %7s %-11s %-30s %d 字'
% (sid if first else '', name, txt, budget_chars(a, b)))
first = False
if len(hit_ids) > limit:
print('\n 還有 %d 個編號沒印。把關鍵字寫長一點可以縮小範圍。' % (len(hit_ids) - limit))
print('\n 同一個編號出現在兩張表,--set 會兩張一起改;要改請用:')
print(' python3 -I %s "%s" --set 編號=新文字' % (os.path.basename(sys.argv[0]), gamedir))
return hit_ids
def _restore_after_failed_verify(path, bak, want_size):
"""複驗沒過就當場還原回去 —— 不要留一個壞掉的檔在那裡等玩家自己發現。
走的是 --restore 同一條路(同樣那五道把關 + 原子換檔),所以
「備份自己也是壞的」會被擋下來,不會把壞的蓋在壞的上面。
還原不成功也不會假裝沒事:回 False,呼叫端照樣用離開碼 2 停掉。
"""
print('\n 複驗沒過 —— 現在自動從備份還原,把遊戲檔放回動手前的樣子。')
try:
n = os.path.getsize(bak)
if n != want_size:
raise DataError('備份 %s bytes,動手前那個檔 %s bytes,兩者不同。'
% (format(n, ','), format(want_size, ',')))
_restore_from_backup(bak, path)
except (Exception, SystemExit) as e:
print(' ✗ 自動還原沒有做成:%s' % e)
print(' 請立刻自己跑一次 --restore,然後回報。')
return False
if path in _WROTE:
_WROTE.remove(path) # 已經放回去了,Ctrl-C 的訊息才不會說錯
print(' ✓ 已自動還原 %s(內容與備份逐位元組相同)' % os.path.basename(path))
return True
def _rollback_written(done):
"""多檔的時候,後面那個檔出事就把前面已經換好的放回去 —— 全有或全無。
⚠️ 2026-09-06 補的。在那之前,--set 同時點到兩個字串表(編號分屬
FEENG.LOC 與 IGENG.LOC)而第二個檔複驗沒過時,第一個檔會**留在新的狀態**:
第二個檔自己會自動還原、離開碼 2,但第一個不會 —— 也就是玩家拿到一個
「一半新一半舊」的遊戲。現在只要有任何一個檔出事,前面換好的都放回去。
走的是 --restore 同一條路(五道把關 + 原子換檔),所以「備份自己也是壞的」
會被擋下來。還原不成功不會假裝沒事:逐檔印出來,叫玩家自己跑 --restore。
"""
if not done:
return
print('\n 前面已經改好的 %d 個檔要放回動手前的樣子(要嘛全部改到,要嘛全部回到原樣):'
% len(done))
for path, bak, size in reversed(done):
try:
n = os.path.getsize(bak)
if n != size:
raise DataError('備份 %s bytes,動手前那個檔 %s bytes,兩者不同。'
% (format(n, ','), format(size, ',')))
_restore_from_backup(bak, path)
except (Exception, SystemExit) as e:
print(' ✗ %s 沒有放回去:%s' % (os.path.basename(path), e))
print(' 請立刻自己跑一次 --restore,然後回報。')
continue
if path in _WROTE:
_WROTE.remove(path)
print(' ✓ 已放回 %s(內容與備份逐位元組相同)' % os.path.basename(path))
def cmd_set(gamedir, sets, apply_it, first_only=False):
"""--set:預覽或寫入。沒有 --apply 就只印表格,一個位元組都不寫。
sets 的鍵有兩種:
· 整數 sid —— 所有含這個編號的字串表一起改(2026-10-29 起的預設)
· ('FE'|'IG', sid) —— 只改那一類表(FE = 選單,IG = 比賽中);蓋過上一種
first_only=True 回到舊行為:整數 sid 只改第一張含它的表。
順序是刻意排的,而且每一步都在寫入之前:
1. 每一條先歸到它所屬的字串表(同一個編號可能在兩個檔裡,每個檔各算一份)
2. 印出「字串表 / 原本 / 改成 / 字數」,太長的標出來
3. 只要有一條太長就整批停住,不做「能寫幾條算幾條」。
每一張表**各自**量(同一個編號在兩張表的槽位不一定一樣大)。
「太長」用的是 UTF-16 的編碼單位,跟真正把關的 write_slots 同一把尺
4. 到這裡都還沒碰過檔案。沒加 --apply 就在這裡結束
5. 加了 --apply 也還會先把**每一個檔**的新內容全部算完,
一個都沒出事才開始寫第一個檔
6. 真的開始寫之後還是全有或全無:第二個檔複驗沒過,第一個檔會被
放回動手前的樣子(見 _rollback_written)
"""
# 先把每一條歸到它所屬的字串表。
# ⚠️ 2026-10-29 改:以前用 find_loc_for,同一個編號只歸到第一張表,
# 所以傳奇隊 / 英雄隊(18099~18104,選單表與比賽中的表各有一份)
# 改完是「選單新名、比賽中舊名」。現在每張含它的表都歸一份。
by_file = {}
shared_ids = [] # 被歸到不只一張表的編號,預覽時提醒
def _put(path, slots, sid, text):
by_file.setdefault(path, ({}, slots))[0][sid] = text
for key, text in sets.items():
if isinstance(key, tuple):
continue
hits = find_locs_for(gamedir, key)
if not hits:
raise DataError('四個字串表裡都找不到編號 %d。先用 --list 或 --find 看有哪些。' % key)
use = hits[:1] if first_only else hits
if len(use) > 1:
shared_ids.append(key)
for path, slots in use:
_put(path, slots, key, text)
for key, text in sets.items(): # 指定了 FE: / IG: 的,蓋過上面的
if not isinstance(key, tuple):
continue
tag, sid = key
hits = find_locs_for(gamedir, sid, tag)
if not hits:
raise DataError('%s 那一類字串表(%s)裡找不到編號 %d。'
% (tag, '、'.join(n for n in LOC_NAMES if table_tag(n) == tag), sid))
for path, slots in hits:
_put(path, slots, sid, text)
rows = []
for path, (changes, slots) in by_file.items():
for sid, text in changes.items():
rows.append((sid, LOC_NAMES.index(os.path.basename(path)), path, text, slots))
rows.sort(key=lambda r: (r[0], r[1]))
print(' %7s %-11s %-24s %-24s %s' % ('編號', '字串表', '原本', '改成', '字數'))
print(' %s' % ('-' * 80))
over = []
for sid, _ord, path, text, slots in rows:
a, b, old = slots[sid]
room = budget_chars(a, b)
# ⚠️ 預算要數 UTF-16 的編碼單位,不是 Python 的字數。BMP 以外的漢字
# (例:𡘙)與 emoji 一個字佔兩個單位,用 len(text) 會少算 ——
# 預覽放行、write_slots 才擋,多檔時前面那個檔已經寫進去了。
# 2026-09-05 實測:--set 627=離開 --set 142=𡘙×8 --apply
# 會先寫完 FEENG.LOC 再在 IGENG.LOC 中止,而檔頭與本課頁面
# 都寫著「整批停住」。兩把尺換成同一把之後就不會了。
need = len(text.encode('utf-16-le')) // 2
flag = '' if need <= room else ' ← 太長'
if need > room:
over.append((sid, need, room))
print(' %7d %-11s %-24s %-24s %d/%d%s'
% (sid, os.path.basename(path), old, text, need, room, flag))
if shared_ids:
print('\n ※ 編號 %s 在兩張字串表(選單與比賽中)都有,兩張一起改,畫面才一致。'
% '、'.join(str(x) for x in sorted(shared_ids)))
print(' 只想改一張:編號前面加 FE: 或 IG:(例:--set IG:%d=...)。' % sorted(shared_ids)[0])
if over:
print('\n ✗ 有 %d 條放不下。本工具只做原地覆寫,新文字不能比原本長。' % len(over))
print(' (同一個編號在兩張表的空間不一定一樣大,任何一張放不下就整批停住,兩張都不動。)')
print(' (為什麼不能改長:.LOC 是「位移表 + 一整塊文字」,改長就要重算')
print(' 後面每一條的位移,6,352 條裡錯一個,遊戲讀到的就是別條的中間。)')
raise DataError('請把太長的那幾條改短一點。')
if not apply_it:
print('\n 這是預覽,沒有改到任何檔案。確定要改請加上 --apply。')
return
# 真正寫入。**先把每一個檔的新內容全部算完,一個都沒出事才開始動檔案。**
# ⚠️ 這一段以前是「算一個寫一個」:上面的預覽跟 write_slots 用不同的尺量
# 「太長」時,第一個檔已經備份並寫進去了才在第二個檔中止 ——
# 也就是檔頭與本課頁面同時宣稱不會發生的「能寫幾條算幾條」。
# 尺已經統一了(見上面 need),這裡再擋一次:write_slots 只要有任何一條
# 丟例外,就會在還沒碰過任何檔案的時候丟出去。
prepared = []
for path, (changes, slots) in by_file.items():
d = open(path, 'rb').read()
prepared.append((path, d, write_slots(d, slots, changes), changes, slots))
# 到這裡為止一個位元組都還沒寫。以下才動檔案:備份 → 寫暫存檔 →
# fsync 落地 → os.replace 換過去。中途斷電最多留下一個 .tmp,
# 正本要嘛是舊的、要嘛是新的,不會是半截的。
# ⚠️ 多檔的時候要嘛全部改到、要嘛全部回到動手前(2026-09-06 補)。
# 一個 --set 可能同時點到兩個字串表,第二個檔出事時第一個檔已經是新的了。
# Ctrl-C 不走這條回滾:那時候玩家要的是「立刻停」,再動檔案反而更嚇人 ——
# 走的是收尾那條**誠實**路徑(逐檔列出已經改好的哪幾個 + 印還原指令,離開碼 130)。
done_ok = []
try:
for path, d, new, changes, slots in prepared:
bak = path + BAK_SUFFIX
# 動手之前先擋符號連結:正本或備份是連結的話,寫下去會改到資料夾外面。
# ⚠️ 用 islink 不用 exists —— exists 對「指向不存在目標的連結」回 False,
# 那種連結照樣會把寫入導到外面去。lexists 才看得到連結本身。
_refuse_if_symlink(path, '要改的那個字串表')
_refuse_if_symlink(bak, '備份檔')
if not os.path.lexists(bak):
_atomic_copy(path, bak)
print('\n ✓ 已備份:%s' % os.path.basename(bak))
else:
print('\n · 備份已存在,保留最早那一份:%s' % os.path.basename(bak))
# 暫存檔用 mkstemp 開在同一個資料夾裡(同一個檔案系統,os.replace 才是原子的)。
# ⚠️ 不寫死 path + '.tmp':那個名字如果剛好已經存在(上一次被中斷留下的、
# 或使用者自己的檔),就會被無聲蓋掉。mkstemp 保證拿到一個全新的名字。
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or '.',
prefix=os.path.basename(path) + '.', suffix='.tmp')
try:
with os.fdopen(fd, 'wb') as f:
f.write(new)
f.flush()
os.fsync(f.fileno())
# ⚠️ 新建的暫存檔用的是行程 umask(mkstemp 更嚴,是 0600),不是原檔的權限。
# 不補這一段的話,os.replace 之後遊戲檔的權限會被換掉
# (寫死 open() 的舊寫法實測是 0700 → 0644)。
# 只複製權限位元,不動時間戳 —— 改過的檔本來就該有新的修改時間。
try:
os.chmod(tmp, os.stat(path).st_mode & 0o7777)
except OSError:
pass
# 換名與登記綁成不可切開的一段(見 _NoInterrupt)。
# 中間態先登記:萬一連 _NoInterrupt 都裝不上,收尾至少說得出
# 「中斷的時候正在替換哪一個檔」,而不是「什麼都沒有動到」。
_REPLACING.append(path)
try:
with _NoInterrupt():
os.replace(tmp, path)
_WROTE.append(path) # 換過了 —— Ctrl-C 時不可以說「什麼都沒動」
_REPLACING.remove(path)
except OSError:
if path in _REPLACING:
_REPLACING.remove(path)
raise
except BaseException:
# 寫到一半出事(磁碟滿、Ctrl-C):把暫存檔收乾淨,正本一個位元組都沒動。
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError:
pass
raise
# ── 寫入後複驗 ────────────────────────────────
# 複驗是重新從磁碟讀回來,不是拿記憶體裡那份來比。
# 拿記憶體比只證明「我算對了」,讀回來才證明「檔案真的變成那樣」。
# ⚠️ 四個數字都印,而且**四件只要有一件對不上就停**(2026-09-05 改;
# 在那之前只擋前兩件,另外兩件只印數字,對不上也照樣印「完成」、
# 離開碼 0 —— 等於把「這個檔可能已經壞了」交給玩家自己看數字)。
# 停下來的時候會當場自動從備份還原,離開碼是 2。
d2, slots2 = loc_slots(path)
bad = [sid for sid, t in changes.items() if slots2.get(sid, (0, 0, None))[2] != t]
# ⚠️ 「沒點名的」不能一律拿 len(slots) - len(changes) 當分母:
# LOCI 的索引項是 (LOCL 索引 << 16) | 字串編號,**兩個編號可以指到
# 同一個 LOCL 索引**,那它們就是同一個槽位,改一個另一個一定跟著變。
# 本站量到的四份原廠字串表(測試機與剛安裝好的原版各兩個檔)都剛好
# 有一組:編號 0 與編號 8 共用索引 0(內容是佔位用的 <some text>)。
# 不先把這種扣掉,改到那兩個編號就會被自己的複驗誤判成「檔案壞了」。
spans = {(slots[sid][0], slots[sid][1]) for sid in changes}
shared = [sid for sid in slots
if sid not in changes and (slots[sid][0], slots[sid][1]) in spans]
quiet = [sid for sid in slots if sid not in changes and sid not in shared]
untouched = sum(1 for sid in quiet
if slots2.get(sid, (0, 0, None))[2] == slots[sid][2])
print(' ✓ 已寫入 %s' % os.path.basename(path))
print(' 檔案大小 %s bytes(原本 %s,**必須相同**)'
% (format(len(d2), ','), format(len(d), ',')))
print(' 字串總數 %d 條(原本 %d 條)' % (len(slots2), len(slots)))
print(' 改的複驗 %d/%d 正確' % (len(changes) - len(bad), len(changes)))
print(' 沒動到的 %d 條逐字相同(共 %d 條沒點名)' % (untouched, len(quiet)))
if shared:
print(' 共用槽位 編號 %s 跟你點名的那幾條是同一個槽位,會一起變'
'(不是異常)' % '、'.join(str(x) for x in sorted(shared)))
problems = []
if len(d2) != len(d):
problems.append('檔案大小從 %s 變成 %s bytes'
% (format(len(d), ','), format(len(d2), ',')))
if bad:
problems.append('點名要改的那幾條有 %d 條沒寫成你要的字' % len(bad))
if len(slots2) != len(slots):
problems.append('字串總數從 %d 條變成 %d 條' % (len(slots), len(slots2)))
if untouched != len(quiet):
problems.append('沒點名的字串有 %d 條跟原本不一樣' % (len(quiet) - untouched))
if problems:
_restore_after_failed_verify(path, bak, len(d))
raise DataError('複驗沒過:%s。請回報。' % '、'.join(problems))
done_ok.append((path, bak, len(d)))
except Exception:
_rollback_written(done_ok)
raise
print('\n 完成。要還原:--restore')
def cmd_restore(gamedir):
"""--restore:把四個候選字串表中有備份的那些還原回去。
四道把關:
0. 備份或正本是**符號連結**就拒絕。順著它寫下去改到的是資料夾外面
那個檔,而 os.replace 只會換掉連結本身 —— 外面那個已經沒了。
⚠️ 判斷要用 os.path.lexists / islink:exists 對「指向不存在目標的
連結」回 False,那種連結會被無聲跳過。
1. 備份的開頭要是 LOCH(拿錯檔、0 bytes 的備份都在這裡擋掉)
2. **備份與現在那個檔的長度必須完全相同。** 這一支只做同長度的原地覆寫,
所以它自己留下的備份一定跟正本一樣大;不一樣就只有兩種可能 ——
備份那次出事了(半截),或者這個 .LOC 後來被別的東西換掉了。
兩種都不該讓它蓋下去。
⚠️ 這一道是 2026-09-05 補的,補的是一個真的會吃掉檔案的窗口:
把 416,753 bytes 的備份截成 416,336(只少最後 417 個位元組),
底下 _restore_from_backup 的五道全部驗得過、印「✓ 已還原」、
exit code 0,正本當場被短掉的檔覆蓋。
3. 交給 _restore_from_backup 驗它的內部結構完不完整,
再由 _do_copy 原子地換上去(寫暫存檔 → 比 sha256 → os.replace)。
只認 .menutextbak,不會去碰別課留下的 .bak。
"""
done = 0
for name in LOC_NAMES:
p = os.path.join(gamedir, 'data', name)
bak = p + BAK_SUFFIX
if not os.path.lexists(bak):
continue
# lexists 之後緊接著擋連結:順序不能反,不然 dangling 的連結會被跳過。
_refuse_if_symlink(bak, '備份檔')
_refuse_if_symlink(p, '要還原的那個遊戲檔')
with open(bak, 'rb') as f:
if f.read(4) != b'LOCH':
raise DataError('備份不是字串表,不敢拿它覆蓋:%s' % os.path.basename(bak))
if os.path.isfile(p):
nbak, live = os.path.getsize(bak), os.path.getsize(p)
if nbak != live:
raise DataError(
'備份跟現在的 %s 長度不一樣,不敢拿它覆蓋。\n'
' 備份 %s bytes,現在的檔 %s bytes。\n'
' 本工具只做同長度的原地覆寫,自己留下的備份一定跟正本一樣大。\n'
' 不一樣代表:備份那次被中斷(半截),或這個檔後來被別的東西換掉了。\n'
' 請改用你自己另外留的那一份完整備份。'
% (name, format(nbak, ','), format(live, ',')))
try:
_restore_from_backup(bak, p)
except SystemExit as e:
# 共用零件用 raise SystemExit 報錯,那樣 exit code 會是 1,
# 跟檔頭寫的「1 = 沒給遊戲資料夾、2 = 檔案內容不如預期」對不上。
# 轉成 DataError,訊息一字不改,只讓離開碼回到 2。
raise DataError('%s' % e)
print(' ✓ 已還原 %s' % name)
done += 1
if not done:
raise DataError('找不到本工具的備份(%s)。\n'
' 本工具只認自己這一個,不會去動別課留下的 .bak。' % BAK_SUFFIX)
def _selftest_clean():
"""--selftest 的外殼:自我測試用 tempfile.mkdtemp() 造的假遊戲資料夾,全收進這裡
自己開的一個資料夾,跑完(成敗、Ctrl-C 都一樣)整個刪掉,不在系統暫存區留東西。
(2026-10-30 前每跑一次會在暫存區留下六個資料夾沒清。)"""
root = tempfile.mkdtemp(prefix='mvp_menu_text_selftest_')
real_tempdir = tempfile.tempdir
tempfile.tempdir = root
try:
return selftest()
finally:
tempfile.tempdir = real_tempdir
shutil.rmtree(root, ignore_errors=True)
def main():
"""命令列入口。--selftest 最優先,因為它不需要遊戲資料夾。
回傳值就是 exit code:0 成功、1 是沒給遊戲資料夾(印說明)、
2 是檔案內容或參數不如預期(DataError)、130 是被 Ctrl-C 中斷。
分開是為了讓看輸出的人分得出「我打錯了」跟「這個遊戲資料夾有問題」。
"""
ap = argparse.ArgumentParser(
description='改 MVP Baseball 2005 選單上的文字',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
例子(照順序做):
1. 看主選單有哪些字可以改(唯讀)
python3 -I mvp_menu_text.py "<遊戲資料夾>" --list
字不在主選單上(例如球隊名),用字找編號(唯讀)
python3 -I mvp_menu_text.py "<遊戲資料夾>" --find Legends
2. 預覽(不會動到遊戲)
python3 -I mvp_menu_text.py "<遊戲資料夾>" --set 12=馬上開打 --set 627=離開
3. 確定了才真的改
python3 -I mvp_menu_text.py "<遊戲資料夾>" --set 12=馬上開打 --apply
出問題就還原:
python3 -I mvp_menu_text.py "<遊戲資料夾>" --restore
⚠️ 只能改成**同長或更短**的文字。本工具是原地覆寫:檔案長度不變、
位移表不動、你沒點名的字串保證逐位元組相同。中文字少,英文換中文通常夠用。
⚠️ 改成中文之前,你的遊戲要先有中文字型,否則會變成空白或方框。
''')
ap.add_argument('gamedir', nargs='?', help='遊戲資料夾(裡面看得到 data)')
ap.add_argument('--list', action='store_true', help='看有哪些字可以改(唯讀)')
ap.add_argument('--find', metavar='字', default=None,
help='在字串表裡找含這個字的字串(唯讀,不限畫面)')
ap.add_argument('--set', action='append', metavar='編號=新文字', default=[],
help='改一條字串,可以寫很多次;同編號在選單與比賽中的表都有就兩張一起改,'
'只改一張用 FE:編號=… 或 IG:編號=…')
ap.add_argument('--first-only', action='store_true',
help='回到舊行為:同一個編號只改第一張含它的字串表')
ap.add_argument('--fel', default=DEFAULT_FEL, help='要看哪個版面檔(預設主選單)')
ap.add_argument('--big', default=DEFAULT_BIG, help='版面檔在哪個封裝檔裡')
ap.add_argument('--apply', action='store_true', help='真的寫入(沒加就只是預覽)')
ap.add_argument('--restore', action='store_true', help='從備份還原')
ap.add_argument('--selftest', action='store_true', help='自我測試,不碰任何遊戲檔')
args = ap.parse_args()
if args.selftest:
return _selftest_clean()
if not args.gamedir:
ap.print_help()
return 1
try:
if args.restore:
cmd_restore(args.gamedir)
elif args.find is not None:
cmd_find(args.gamedir, args.find)
elif args.set:
# --set 可以寫很多次,一次收齊再一起處理:
# 全部先解析、全部先檢查,不會出現「前兩條寫進去了、第三條才發現太長」。
sets = {}
for spec in args.set:
if '=' not in spec:
raise DataError('--set 要寫成 編號=新文字,你給的是「%s」' % spec)
k, v = spec.split('=', 1)
k = k.strip()
m = re.match(r'^(FE|IG):(\d+)$', k, re.IGNORECASE)
if m:
sets[(m.group(1).upper(), int(m.group(2)))] = v
elif k.isdigit():
sets[int(k)] = v
else:
raise DataError('編號要是數字(只改一張表就寫 FE:數字 或 IG:數字),'
'你給的是「%s」' % k)
cmd_set(args.gamedir, sets, args.apply, first_only=args.first_only)
else:
cmd_list(args.gamedir, args.big, args.fel)
except DataError as e:
print('\n 停下來了:%s\n' % e)
return 2
except KeyboardInterrupt:
# ⚠️ 不可以一律印「什麼都沒有動到」:os.replace 如果已經做過了,
# 遊戲檔就已經是新的那一份。這兩個清單記的就是「換過了沒有」,
# 沒有它們,這句話有一半的時候是假的。兩種情況都回 130,不是 0。
# ⚠️ _REPLACING 是「換名做到一半」的中間態。正常情況它永遠是空的
# (換名與登記被 _NoInterrupt 綁在一起);它不是空的,代表連
# _NoInterrupt 都沒裝上,那就**不可以**猜,只能照實說。
if _REPLACING:
print('\n 已中斷,而且中斷的時候正在替換下面這些檔:')
for q in _REPLACING:
print(' %s' % q)
print(' 換檔是原子的,所以它要嘛還是舊的、要嘛已經是新的,'
'不會是半截 —— 但這裡分不出是哪一種。')
print(' 請跑一次 --restore 還原,或自己跟 %s 比對過再繼續。' % BAK_SUFFIX)
if _WROTE:
print(' 另外下面這些**已經改好了**:')
for q in _WROTE:
print(' %s' % q)
print('')
elif _WROTE:
print('\n 已中斷。⚠️ 下面這些檔**已經改好了**'
'(換檔是原子的,所以是完整的新版,不是半截):')
for q in _WROTE:
print(' %s' % q)
print(' 要回到動手前的樣子:--restore\n')
elif _RESTORED:
print('\n 已中斷。已經還原回去的是:%s;其餘沒有動到。\n'
% '、'.join(os.path.basename(q) for q in _RESTORED))
else:
print('\n 已中斷。什麼都沒有動到。\n')
return 130
return 0
# ─────────────────────────────────────────────────────────
# 自我測試:不碰任何遊戲檔,自己造一個最小的 .LOC 來做。
# ─────────────────────────────────────────────────────────
def _fake_loc(pairs):
"""造一個能被 loc_slots 讀懂的最小字串表。pairs = [(編號, 文字), ...]
結構照真檔量到的(FEENG.LOC / IGENG.LOC 皆同):
0-20 LOCH:magic + 20 + 1 + 1 + LOCL 的位移
20-32 LOCI:magic + loci_size + icnt
32- 索引項,每 4 bytes = (LOCL 索引 << 16) | 字串編號
後面補 4 個位元組 —— **loci_size = 16 + 4*n 不是 12 + 4*n**
(真檔:icnt 6352、loci_size 25424 = 16 + 4*6352)
L- LOCL:magic + lsize + 保留 4 + lcnt + 位移表 + UTF-16LE 內容
⚠️ 第一版憑推的寫成 12 + 4*n,少 4 個位元組,LOCL 的 magic 就對不上,
自我測試自己先掛掉。**先看真檔的位元組再造測試資料。**
"""
texts = [t.encode('utf-16-le') + b'\x00\x00\x00\x00' for _s, t in pairs]
offs, cur = [], 16 + 4 * len(texts)
for t in texts:
offs.append(cur)
cur += len(t)
lsize = cur
locl = (b'LOCL' + struct.pack('<I', lsize) + b'\x00' * 4
+ struct.pack('<I', len(texts))
+ b''.join(struct.pack('<I', o) for o in offs)
+ b''.join(texts))
loci_body = b''.join(struct.pack('<I', (i << 16) | sid)
for i, (sid, _t) in enumerate(pairs))
loci_size = 16 + 4 * len(pairs)
loci = b'LOCI' + struct.pack('<I', loci_size) + struct.pack('<I', len(pairs)) \
+ loci_body + b'\x00' * 4
L = 20 + loci_size
loch = (b'LOCH' + struct.pack('<I', 20) + struct.pack('<I', 1)
+ struct.pack('<I', 1) + struct.pack('<I', L))
return loch + loci + locl
def selftest():
"""--selftest:自己造一個最小的字串表來測,完全不碰遊戲檔。
測的是這支工具的核心承諾,一條一條驗:
· 造出來的字串表讀得出來,而且文字跟放進去的一樣
· 每個槽位算出來的可用字數等於原字串長度
· 改短之後檔案長度不變,沒點名的字串逐字相同
· 完整的備份還原得回去,而且逐位元組回到原本的樣子
上面這四條在程式裡是 7 道檢查(含改完讀回來對、沒點名的逐字相同),都是正向的。
後面標成「反向餌」的是 6 塊、另外 10 道:改成同長要能過、太長要被擋、
壞檔與不存在的檔都要回空的(各 1 道)、UTF-16 的兩條編碼假設、
BMP 以外的字要佔兩個編碼單位(2 道)、
長度對不上的備份要被 --restore 擋下來而且檔案不可以被動到(2 道)。
這一段合計 17 道(15 條 assert 加 2 組必須丟出例外的 try)。
這 6 塊裡真正「故意做一件必須失敗的事」的是「太長要被擋」與
「長度對不上的備份要被擋」那兩道,其餘是餵壞資料或編碼假設的一般斷言,
失敗不了才是問題。
2026-09-05 又加了三組(每一組都是「先讓舊寫法咬得到」才算數的餌):
· 猜得到的暫存檔名字被人先佔住:在資料夾裡放好 <正本>.part /
<備份>.part / .tmp 四條指向**資料夾外面**的符號連結,再跑一次
--apply。舊寫法 shutil.copy2(src, dst + '.part') 會跟著連結
把外面那個檔覆蓋掉;現在的 mkstemp 拿到的是隨機名字,碰不到它。
同一趟還做正向對照(備份真的生出來、字真的改到了),
不然「外面那個檔沒被動到」有可能只是因為整支根本沒跑
· 備份檔本身、以及字串表本身是符號連結:兩種都必須當場拒絕,
而且連結指到的那個檔一個位元組都不可以變
· 複驗四件事的第四件(沒點名的字串被寫壞了):把 write_slots 暫時換成
一個順便弄髒一條沒點名字串的版本,模擬「檔案真的被寫壞了」。
必須當場停下來、自動還原、丟 DataError。**這一道是補的**:
2026-09-05 先寫好「四件全擋」才發現自我測試裡沒有東西咬得到它 ——
把那一件拿掉,27 道照樣全綠。防線加了沒配餌,等於沒加
· 還原換檔的那一步失敗(把 os.replace 換成會丟例外的假貨):
正本必須原封不動,而且不可以留下暫存檔。舊寫法 copy2(bak, dst)
那一刀下去正本當場被截成 0,根本沒有 os.replace 可以攔
2026-09-06 又加了一組,並且拒絕在 python -O 下跑:
· --restore 的**目的地**(要被還原的那個遊戲檔)是一條符號連結:
要當場拒絕,連結指到的那個檔一個位元組都不可以變。
寫入的目的位置有三種(遊戲檔、備份檔、還原目標),前兩種本來就有餌,
這一道補上第三種。外面那個檔刻意做成跟備份一樣長,
不然「長度必須相同」那一道會先攔下來,餌就咬不到連結那一道
· python -O 會把 assert 全部拿掉,這一段的正向檢查幾乎都是 assert ——
真的跑下去會一路印到「全部通過」。所以 -O 直接拒絕,離開碼 2
2026-09-24 再加一組(兩道):上面那道 -O 守門自己的餌 ——
· 把 _optimize_level 換成「回傳 1」(假裝開了 -O),自我測試必須拒跑,
這一道算反向餌;換回原本那一支之後守門要照樣放行,這一道是它的陰性對照
符號連結那幾道在建不了連結的系統(Windows 沒開開發者模式)上會跳過,
而且**會印出來說跳過了**,不會默默當成通過。所以總道數是算出來的,
不是寫死的 —— 寫死的數字改了程式就會過期。
餌會失效而沒有人發現(2026-08-29 本站踩過:防線壞了測試照樣全綠),
所以正向與反向都要有。
"""
# ⚠️ python -O 會把整支程式裡的 assert **全部拿掉**。這一段的正向檢查
# 幾乎都是 assert,拿掉之後測試會一路跑到最後印「全部通過」——
# 綠燈是假的。所以 -O 直接拒絕跑,不留這個假綠的窗口。
if _optimize_level():
print('--selftest 不能在 python -O 下跑:-O 會把 assert 全部拿掉,測試會假綠')
return 2
# 這一行要緊接在守門後面:_optimize_bait() 的陰性對照靠它收工(見那個函式)。
_opt = _optimize_bait(selftest)
import io
import contextlib
total, baits = 17, 10 # 上面 docstring 說的那一段,下面才開始加
# 最上面那道 -O 守門自己的餌(開頭那一行 _optimize_bait() 已經量好了,這裡只記帳)
assert _opt[0], '假裝開了 -O(把 _optimize_level 換成回傳 1),自我測試竟然沒有拒跑'
assert _opt[1], '陰性對照:沒開 -O 守門也擋,或是換回原本那一支沒換成功'
total += 2; baits += 1
pairs = [(12, 'Play Now'), (627, 'Quit'), (13, 'Game Modes')]
blob = _fake_loc(pairs)
d = tempfile.mkdtemp()
os.makedirs(os.path.join(d, 'data'))
p = os.path.join(d, 'data', 'FEENG.LOC')
open(p, 'wb').write(blob)
raw, slots = loc_slots(p)
assert raw is not None, '自造的字串表讀不出來 —— 測試本身壞了'
assert {s: t for s, (_a, _b, t) in slots.items()} == dict(pairs), '讀出來的文字不對'
for sid, txt in pairs:
a, b, _t = slots[sid]
assert budget_chars(a, b) == len(txt), '預算算錯:%d' % sid
# 這裡開始是正向:先證明「正常流程真的會做到承諾的事」。
# 少了正向,反向餌可能是「因為整支都壞了」才過的。
# 改短:長度不變、別條不動
new = write_slots(raw, slots, {12: '打'})
assert len(new) == len(raw), '長度變了 —— 原地覆寫的前提破了'
open(p, 'wb').write(new)
_r2, s2 = loc_slots(p)
assert s2[12][2] == '打', '改完讀回來不對'
assert s2[627][2] == 'Quit' and s2[13][2] == 'Game Modes', '沒點名的字串被動到了'
# 反向餌:改成同長要能過
new2 = write_slots(raw, slots, {627: '離開嗎啊'})
assert len(new2) == len(raw), '同長度也不該改變長度'
# 反向餌:太長一定要被擋
try:
write_slots(raw, slots, {627: '這句話太長了放不下'})
except DataError:
pass
else:
raise AssertionError('太長的字串竟然寫得進去 —— 防線失效')
# 反向餌:「長度不變」那道最後防線(2026-10-29 由 assert 改成 raise,-O 拿不掉它)。
# 槽位終點超出檔案結尾(字串表檔頭宣稱的長度比真檔長),切片賦值會把檔案撐長:
# 必須丟 DataError 而且訊息提到「長度變了」。舊寫法是 assert,丟的是 AssertionError,
# 下面的 except DataError 接不住,自我測試會當場紅。
_a13, _b13, _t13 = slots[13]
try:
write_slots(raw, {13: (_a13, len(raw) + 100, _t13)}, {13: 'x'})
except DataError as _err:
assert '長度變了' in str(_err), '丟的 DataError 不是長度那一道:%s' % _err
else:
raise AssertionError('槽位超出檔案結尾竟然照寫 —— 長度承諾的最後一道防線失效')
total += 2; baits += 2
# 反向餌:壞掉的檔要回 (None, {}) 不可以爆掉
bad = os.path.join(d, 'data', 'BAD.LOC')
open(bad, 'wb').write(b'NOTLOC' + b'\x00' * 40)
assert loc_slots(bad) == (None, {}), '壞檔沒有被擋下來'
assert loc_slots(os.path.join(d, 'nope.LOC')) == (None, {}), '不存在的檔應該回空的'
# 反向餌:UTF-16 的中文一個字兩個位元組,預算不可以用位元組數算
assert len('馬上開打'.encode('utf-16-le')) == 8, 'UTF-16 編碼假設變了'
assert budget_chars(0, 20) == 8, '預算換算錯(20 bytes 應該是 8 個字)'
# 反向餌:BMP 以外的字一個字要**兩個**編碼單位,而 Python 的 len() 只算一個。
# 預覽如果拿 len() 當預算就會少算,那正是 2026-09-05 修掉的那個窗口。
assert len('\U00021619'.encode('utf-16-le')) // 2 == 2, '非 BMP 的字應該佔兩個編碼單位'
assert len('\U00021619') == 1, 'Python 的 len() 只算一個 —— 所以不能拿它當預算'
# 反向餌:--restore 的「備份與正本長度必須相同」那一道(2026-09-05 補的)。
# 餌故意只讓長度不同、結構完全正常(備份 = 完整檔案再多一個位元組),
# 這樣結構那幾道都會放行,只有長度比得出來 —— 餌才咬得到那一道。
live_before = open(p, 'rb').read()
open(p + BAK_SUFFIX, 'wb').write(blob + b'\x00')
try:
cmd_restore(d)
except DataError:
pass
else:
raise AssertionError('長度對不上的備份竟然還原得下去 —— 防線失效')
assert open(p, 'rb').read() == live_before, '被擋下來的時候檔案不可以被動到'
# 正向:長度一樣的完整備份要真的還原得動 ——
# 少了這一條,上面那個餌可能只是「整支都壞了」才過的。
open(p + BAK_SUFFIX, 'wb').write(blob)
with contextlib.redirect_stdout(io.StringIO()):
cmd_restore(d)
assert open(p, 'rb').read() == blob, '還原之後應該逐位元組回到原本的樣子'
# ── 2026-09-05 加的三組。先準備一個「資料夾外面的檔」當受害者。 ──
outside = tempfile.mkdtemp()
victim = os.path.join(outside, 'victim.bin')
KEEP = b'DO NOT TOUCH'
open(victim, 'wb').write(KEEP)
def _symlinks_work(where):
"""Windows 沒開開發者模式就建不了符號連結。建不了要**說**,不要裝作過了。"""
probe = os.path.join(where, '.symlink-probe')
try:
os.symlink(os.path.join(where, 'nothing-here'), probe)
except (OSError, NotImplementedError, AttributeError):
return False
os.remove(probe)
return True
if _symlinks_work(d):
# 反向餌 A:舊寫法用得到的那幾個「猜得到的名字」先被連結佔住。
os.remove(p + BAK_SUFFIX) # 讓它需要重新備份
lures = (p + '.part', p + BAK_SUFFIX + '.part',
p + '.tmp', p + BAK_SUFFIX + '.tmp')
for lure in lures:
os.symlink(victim, lure)
with contextlib.redirect_stdout(io.StringIO()):
cmd_set(d, {627: '掰'}, True)
assert open(victim, 'rb').read() == KEEP, \
'資料夾外面的檔被改到了 —— 暫存檔的名字被猜到並劫持'
total += 1; baits += 1
# 正向對照:這一趟真的有做事,不然上面那條可能只是因為整支沒跑。
assert open(p + BAK_SUFFIX, 'rb').read() == blob, '備份沒有正確產生'
assert loc_slots(p)[1][627][2] == '掰', '字沒有真的改到'
total += 2
for lure in lures:
os.remove(lure)
# 反向餌 B:備份檔本身是一條連結 —— 要當場拒絕,外面那個檔不可以動。
os.remove(p + BAK_SUFFIX)
os.symlink(victim, p + BAK_SUFFIX)
try:
with contextlib.redirect_stdout(io.StringIO()):
cmd_set(d, {627: 'Bye'}, True)
except DataError:
pass
else:
raise AssertionError('備份檔是符號連結竟然照寫 —— 防線失效')
assert open(victim, 'rb').read() == KEEP, '外面那個檔被當成備份寫掉了'
os.remove(p + BAK_SUFFIX)
total += 2; baits += 2
# 反向餌 C:要改的字串表本身是一條連結 —— 同樣拒絕。
d2 = tempfile.mkdtemp()
os.makedirs(os.path.join(d2, 'data'))
real = os.path.join(outside, 'REAL.LOC')
open(real, 'wb').write(blob)
os.symlink(real, os.path.join(d2, 'data', 'FEENG.LOC'))
try:
with contextlib.redirect_stdout(io.StringIO()):
cmd_set(d2, {627: '嗨'}, True)
except DataError:
pass
else:
raise AssertionError('字串表是符號連結竟然照寫 —— 防線失效')
assert open(real, 'rb').read() == blob, '順著連結改到資料夾外面的檔了'
total += 2; baits += 2
# 反向餌 F:--restore 的**目的地**(要被還原的那個遊戲檔)是一條連結。
# 寫入的目的位置有三種:遊戲檔、備份檔、還原目標。A/B/C 咬前兩種,
# 這一道咬第三種。外面那個檔刻意做成**跟備份一樣長**(只有最後一個
# 位元組不同),不然長度那一道會先攔下來,這個餌就咬不到連結那一道。
d3 = tempfile.mkdtemp()
os.makedirs(os.path.join(d3, 'data'))
real3 = os.path.join(outside, 'REAL3.LOC')
same_len = bytes(blob[:-1]) + bytes([blob[-1] ^ 0xFF])
open(real3, 'wb').write(same_len)
p3 = os.path.join(d3, 'data', 'FEENG.LOC')
os.symlink(real3, p3)
open(p3 + BAK_SUFFIX, 'wb').write(blob)
try:
with contextlib.redirect_stdout(io.StringIO()):
cmd_restore(d3)
except DataError:
pass
else:
raise AssertionError('要還原的檔是符號連結竟然照還原 —— 防線失效')
assert open(real3, 'rb').read() == same_len, '順著連結還原到資料夾外面的檔了'
total += 2; baits += 2
else:
print(' (這台機器建不了符號連結,跳過 9 道符號連結的餌)')
# 反向餌 E:複驗那四件事,任何一件對不上都要停下來、自動還原、離開碼非 0。
# 挑第四件(沒點名的字串)來咬,因為它最容易被寫成「只印數字不擋」——
# 2026-09-05 之前正是如此:印完數字照樣印「完成」、離開碼 0。
open(p, 'wb').write(blob)
if os.path.lexists(p + BAK_SUFFIX):
os.remove(p + BAK_SUFFIX)
real_write = write_slots
def _dirty(data, slots, changes):
out = bytearray(real_write(data, slots, changes))
a, _b, _t = slots[13] # 13 沒被點名,弄髒它
out[a:a + 2] = b'ZZ'
return bytes(out)
globals()['write_slots'] = _dirty # cmd_set 是從模組 globals 找它的
try:
with contextlib.redirect_stdout(io.StringIO()):
cmd_set(d, {12: '打'}, True)
except DataError:
pass
else:
raise AssertionError('沒點名的字串被寫壞了竟然還印「完成」 —— 四道複驗沒有全擋')
finally:
globals()['write_slots'] = real_write
assert open(p, 'rb').read() == blob, '複驗沒過的時候應該自動還原成動手前的樣子'
total += 2; baits += 2
# 反向餌 D:還原換檔的那一步失敗,正本必須原封不動、不可以留暫存檔。
# 把 os.replace 換成會丟例外的假貨。舊寫法是 copy2(備份, 正本),
# 那一刀下去正本當場被截成 0,根本走不到 os.replace ——
# 所以這道餌測的正是「有沒有先寫暫存檔」。
open(p, 'wb').write(blob)
open(p + BAK_SUFFIX, 'wb').write(blob)
live_before = open(p, 'rb').read()
real_replace = os.replace
def _boom(_a, _b):
raise OSError('假裝換檔失敗(自我測試用)')
os.replace = _boom
try:
with contextlib.redirect_stdout(io.StringIO()):
cmd_restore(d)
except OSError:
pass
else:
raise AssertionError('換檔失敗竟然沒有反應 —— 還原不是原子的')
finally:
os.replace = real_replace
assert open(p, 'rb').read() == live_before, '還原失敗的時候正本被動到了'
leftover = [x for x in os.listdir(os.path.join(d, 'data')) if '.restore-' in x]
assert not leftover, '還原失敗留下了暫存檔:%s' % leftover
total += 3; baits += 3
# ── 2026-10-29 加的一組:同一個編號在選單表(FE)和比賽中的表(IG)都有 ──
# 起因:傳奇隊 / 英雄隊的名字(編號 18099~18104)兩張表各有一份,舊寫法
# (find_loc_for 只回第一張)改完選單是新的、比賽中還是舊的。
# 每一道都先證明「舊寫法咬得到」才算數:舊寫法 = first_only=True。
def _two_tables(fe_pairs, ig_pairs):
t = tempfile.mkdtemp()
os.makedirs(os.path.join(t, 'data'))
fe_p, ig_p = os.path.join(t, 'data', 'FEENG.LOC'), os.path.join(t, 'data', 'IGENG.LOC')
fe_b, ig_b = _fake_loc(fe_pairs), _fake_loc(ig_pairs)
open(fe_p, 'wb').write(fe_b)
open(ig_p, 'wb').write(ig_b)
return t, fe_p, ig_p, fe_b, ig_b
def _quiet_set(gd, sets, apply_it=True, **kw):
with contextlib.redirect_stdout(io.StringIO()):
return cmd_set(gd, sets, apply_it, **kw)
FE_PAIRS = [(100, 'MVP Legends'), (101, 'Legends'), (12, 'Play Now')]
IG_PAIRS = [(100, 'MVP Legends'), (101, 'Legends'), (500, 'Ball')]
t2, fe_p, ig_p, fe_b, ig_b = _two_tables(FE_PAIRS, IG_PAIRS)
# 正向 + 反向餌:預設兩張一起改。IG 那張也要變(舊寫法 IG 不會變)。
_quiet_set(t2, {101: '傳奇隊'})
s_fe, s_ig = loc_slots(fe_p)[1], loc_slots(ig_p)[1]
assert s_fe[101][2] == '傳奇隊', '選單表沒有改到'
assert s_ig[101][2] == '傳奇隊', '比賽中的表沒有一起改 —— 這就是 2026-10-29 修掉的那個洞'
assert len(open(fe_p, 'rb').read()) == len(fe_b) and len(open(ig_p, 'rb').read()) == len(ig_b), \
'兩張表的長度都不可以變'
assert (s_fe[100][2], s_fe[12][2], s_ig[100][2], s_ig[500][2]) == \
('MVP Legends', 'Play Now', 'MVP Legends', 'Ball'), '沒點名的字串被動到了'
total += 4; baits += 1
# 反向餌:舊行為(--first-only)要真的只改第一張,證明上面那道咬得到差別
for q in (fe_p, ig_p):
os.remove(q + BAK_SUFFIX)
open(fe_p, 'wb').write(fe_b); open(ig_p, 'wb').write(ig_b)
_quiet_set(t2, {101: '傳奇隊'}, first_only=True)
assert loc_slots(fe_p)[1][101][2] == '傳奇隊' and loc_slots(ig_p)[1][101][2] == 'Legends', \
'--first-only 應該只改第一張(舊行為)'
total += 1; baits += 1
# 只在比賽中的表有的編號:要能改到那一張,選單那張原封不動
for q in (fe_p, ig_p):
if os.path.lexists(q + BAK_SUFFIX):
os.remove(q + BAK_SUFFIX)
open(fe_p, 'wb').write(fe_b); open(ig_p, 'wb').write(ig_b)
_quiet_set(t2, {500: '球'})
assert loc_slots(ig_p)[1][500][2] == '球' and open(fe_p, 'rb').read() == fe_b, \
'只在 IG 有的編號:IG 要改到、FE 要原封不動'
total += 1
# 指定 IG: 只改比賽中的表;FE 一個位元組都不可以變
for q in (fe_p, ig_p):
if os.path.lexists(q + BAK_SUFFIX):
os.remove(q + BAK_SUFFIX)
open(fe_p, 'wb').write(fe_b); open(ig_p, 'wb').write(ig_b)
_quiet_set(t2, {('IG', 101): '傳奇'})
assert loc_slots(ig_p)[1][101][2] == '傳奇' and open(fe_p, 'rb').read() == fe_b, \
'IG: 只能改比賽中的表,選單表要原封不動'
total += 1; baits += 1
# 反向餌:指定的那一類表裡根本沒有這個編號(12 只在 FE)→ 要丟錯,不可以默默什麼都不做
try:
_quiet_set(t2, {('IG', 12): 'x'}, apply_it=False)
except DataError:
pass
else:
raise AssertionError('IG: 指到 IG 沒有的編號竟然沒報錯 —— 會變成「以為改了其實沒改」')
total += 1; baits += 1
# 反向餌:同編號兩張表的空間不一樣大,任何一張放不下就整批停住,**兩張都不可以動**,
# 而且不可以留下備份(還沒開始寫)
t3, fe3, ig3, fe3_b, ig3_b = _two_tables([(101, 'Legends')], [(101, 'Leg')])
try:
_quiet_set(t3, {101: '傳奇球隊'}) # 4 個字:FE 放得下(7),IG 放不下(3)
except DataError:
pass
else:
raise AssertionError('IG 放不下竟然照寫 —— 「任何一張放不下就整批停住」失效')
assert open(fe3, 'rb').read() == fe3_b and open(ig3, 'rb').read() == ig3_b, \
'整批停住的時候兩張表都不可以被動到'
assert not os.path.lexists(fe3 + BAK_SUFFIX) and not os.path.lexists(ig3 + BAK_SUFFIX), \
'還沒開始寫就不該留下備份'
total += 3; baits += 2
# 同一件事,只看**預覽**(不加 --apply):預覽自己就要擋下來。
# 只靠後面 write_slots 那道最後防線不夠 —— 預覽放行、寫入才擋,
# 正是 2026-09-05 修掉的那種「兩把尺不一致」。
try:
_quiet_set(t3, {101: '傳奇球隊'}, apply_it=False)
except DataError:
pass
else:
raise AssertionError('預覽竟然放行了 IG 放不下的字 —— 預覽與寫入的尺不一致')
total += 1; baits += 1
# 反向餌:編號在任何一張表都沒有 → 要丟錯,不可以印個空表就說完成
try:
_quiet_set(t2, {99999: 'x'}, apply_it=False)
except DataError:
pass
else:
raise AssertionError('不存在的編號竟然沒報錯')
total += 1; baits += 1
# 反向餌:新預設讓「一個 --set 動兩個檔」變成常態,所以全有或全無要有餌:
# 第二個檔(IG)的複驗被弄壞 → 要丟錯,而且**第一個檔(FE)已經寫好的也要放回原樣**
for q in (fe_p, ig_p):
if os.path.lexists(q + BAK_SUFFIX):
os.remove(q + BAK_SUFFIX)
open(fe_p, 'wb').write(fe_b); open(ig_p, 'wb').write(ig_b)
real_write2 = write_slots
def _dirty_ig(data, slots, changes):
out = bytearray(real_write2(data, slots, changes))
if 500 in slots: # 只有 IG 那張有編號 500
a, _b, _t = slots[500] # 500 沒被點名,弄髒它
out[a:a + 2] = b'ZZ'
return bytes(out)
globals()['write_slots'] = _dirty_ig
try:
try:
_quiet_set(t2, {101: '傳奇隊'})
except DataError:
pass
else:
raise AssertionError('第二個檔複驗沒過竟然沒報錯 —— 四道複驗沒有全擋')
finally:
globals()['write_slots'] = real_write2
assert open(fe_p, 'rb').read() == fe_b, \
'第二個檔出事時,第一個檔應該被放回動手前的樣子(全有或全無)'
assert open(ig_p, 'rb').read() == ig_b, '複驗沒過的檔應該自動還原'
total += 2; baits += 2
# --find:大小寫不分、兩張表都列出來、找不到回空的而不是爆掉
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
found = cmd_find(t2, 'LEGENDS')
none_found = cmd_find(t2, '這個字不存在')
assert found == [100, 101], '--find 大小寫不分:應該找到 100 與 101,實際 %r' % (found,)
# ⚠️ 不能只查『輸出裡有沒有 IGENG.LOC 這幾個字』:開頭「查了這幾張字串表」那一行
# 本來就會印出兩個檔名,只列第一張表的壞版本一樣過得了。要查**資料列**:
# 編號 101 的 IG 那一列(檔名後面接著它的文字)真的有印出來。
out_find = buf.getvalue()
assert re.search(r'IGENG\.LOC\s+Legends', out_find) and re.search(r'IGENG\.LOC\s+MVP Legends', out_find), \
'--find 要把兩張表的資料列都列出來(不只第一張)'
assert none_found == [], '找不到要回空的'
total += 3; baits += 1
print('自我測試:全部通過(%d 道檢查,其中 %d 道是反向餌)' % (total, baits))
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__':
sys.exit(main())
# ─────────────────────────────────────────────────────────
# MIT License
#
# Copyright (c) 2026 toni
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# ─────────────────────────────────────────────────────────
⚠️ 寫那個自我測試時我自己也踩了一次: 憑推的把索引區長度寫成「12 + 4×條數」,實際是「16 + 4×條數」 (條目後面有 4 個位元組填充),少 4 個位元組整個結構就對不上。 先看真檔的位元組再造測試資料。
📌 重點整理
- 一句話 選單上的字不在版面檔裡。版面檔只放一個字串編號
(「Play Now」是 12),真正的文字在
data/FEENG.LOC。 所以改選單文字不用動版面檔,改字串表就好。 - 只能改成同長或更短 本工具是原地覆寫 ——
檔案總長度不變、位移表不動、你沒點名的 6,349 條保證逐字相同。
代價是不能改長。在原廠英文的字串表上,每個槽位的預算剛好等於原字串的字數
(剛安裝好的原版與本站測試機那兩份
FEENG.LOC, 6,352 條全部以 2 個 NUL 收尾,零例外)。 被改過的字串表就不是了:2009 年台灣模組那份中文FEENG.LOC有 6,436 條,其中 30 條的預算比原字數少一個字。 預算一律看--list印的「可用字數」。 - 這個限制對英文改中文剛好不是問題 Play Now 有 8 字預算、馬上開打只要 4 字;Quit 4 字、離開 2 字。 反過來把中文改成長英文會被擋,那是刻意的。
- ⚠ 選單按鈕是
TB不是TX第一版只抓 TX,在主選單找到 10 個而且全是同一條字串, 印得很整齊但真正想改的一個都沒出現。主選單實際是 10 個 TX + 31 個 TB,加起來 41 跟介面萬用工具數的一樣。 但那 41 個元素只用到 9 條不同的字串,同一條被好幾個元素 引用時只列一次,所以--list印出來是 9 行,不是 41 行。 兩把尺量出不同答案時,先查哪一把錯。 - 改成中文之前要先有中文字型,否則畫面上是空白或方框。
- 「改主選單有風險」那句話仍然成立,但不是這一課的風險 危險的是關掉選單元素,這一課只換字、結構完全沒動。
- 四項複驗,四項都擋 檔案大小、你點名要改的那幾條、 字串總數、沒動到的那幾千條 —— 任一項對不上就當場停下來, 並自動從備份還原,離開碼 2。 (2026-09-05 訂正:舊版只擋前兩項,另外兩項只印數字。)
- 本站沒驗的 沒有在遊戲裡看過改完的選單; 沒有試過把字串改長(刻意不做); 同一條字串可能被很多畫面共用,改一次全部都變, 本站沒有逐一追過每條字串被誰用到。