教學 › 幫球員換一張臉
幫球員換一張臉
大部分球員都用同一批通用臉(本站測試機七成七,剛安裝好的原版更高,八成四)。這頁教你指定某位球員改用哪一張 3D 臉皮 —— 而且不必改動那個 535.9 MB 的大檔,只要改一個 800 KB 的純文字檔。
難度
★☆☆ 只改一個文字檔
時間
約 10 分鐘
可以還原嗎
可以,一行指令
🛟 動手之前:先完整備份整個遊戲資料夾
把整個遊戲資料夾複製一份到別的地方, 包括裡面存放紀錄的資料夾。不要挑檔案,整包複製最省事也最保險。
「紀錄」是指這些(它們跟遊戲檔混在同一個資料夾裡):
Support\MVP Baseball 2005\—— 遊戲自己建的, 裡面是設定(MVPOptions_Options.sav)與手把設定 (controller.cfg)。剛安裝好時沒有這個資料夾,玩過才出現。- 王朝、老闆模式、名單與玩家檔案的存檔(
*_Franchise.sav、*_Owner Mode.sav、*_Rosters.sav、*_Profile.sav)—— 沒有固定的資料夾名,你或你裝的模組包叫它什麼它就叫什麼。找不到就用這一行找:
dir /s /b "〔你的遊戲資料夾〕\*.sav"
find "〔你的遊戲資料夾〕" -name "*.sav"
上面第一行 Windows、第二行 Mac。 本站每一支工具都會自己備份它要動的那一個檔, 但那只保得住那一個檔 —— 整包備份保的是「你玩到現在的一切」。 完整做法看備份與還原 SOP。
⚡ 只想趕快做完?照這五步
- 下載
mvp_swap_face.py,記得從「下載」資料夾拖到桌面(沒拖就會跳can't open file,這是本站最常見的第一個卡點)。 - 開命令提示字元,切到桌面
Mac 是cd %USERPROFILE%\Desktop
cd ~/Desktop - 查那位球員,把最左邊那串識別碼抄下來(
"你的遊戲資料夾"換成拖進視窗的路徑)
python mvp_swap_face.py "你的遊戲資料夾" --find Ohtani - 先預覽,不會改到任何檔案(臉皮編號填 1 到 900)
python mvp_swap_face.py "你的遊戲資料夾" --set 0fff0f536 123 - 確定了,同一行最後加
--apply;印出「改動行數 = 1」跟「✅ 複驗通過」就成了
python mvp_swap_face.py "你的遊戲資料夾" --set 0fff0f536 123 --apply
不放心的話,先跑這一行 —— 它自己造一份假名冊來測 39 道檢查,一個遊戲檔都不碰:
python mvp_swap_face.py --selftest
後悔的話,這一行還原:
python mvp_swap_face.py "〔你的遊戲資料夾〕" --restore
Mac / Linux 把 python 換成 python3。只要沒加上 --apply,前面幾步都只是預覽,不會改到任何檔案。
想知道為什麼,往下讀;只想複習,跳到📌 重點整理。
為什麼不用碰大檔
很多人以為換臉要動 models.big(535.9 MB)。要看你想做什麼:
| 你想做的事 | 要動哪個檔 | 風險 |
|---|---|---|
| 讓球員改用一張已經存在的臉 | attrib.dat(800 KB 純文字) |
低 這頁教的就是這個 |
| 做一張全新的臉塞進遊戲 | models.big(535.9 MB 二進位) |
高 見做一張新的球員臉皮 |
關鍵在球員資料裡的一個欄位
遊戲怎麼知道某位球員要用哪張臉?答案在
attrib.dat 裡的
playerattrib_face 這一欄:
某位球員那一列的 playerattrib_face = 665
└→ models.big 裡的 c665
改掉那個數字,那位球員就換臉了。就這麼簡單。
要記的是欄位名字,不是欄號。
本站 2026-09-03 把手邊九份內容不重複的名冊逐份查過表頭,
playerattrib_face 九份都排在第 10 欄,但那是運氣不是規定:
同一批名冊裡 playerattrib_audioid 有 16 與 17 兩種、
playerattrib_skin_tone 有 12 與 38 兩種。
所以下面這支腳本每次執行都自己從第一行表頭反查,不寫死欄號;
你自己看的時候也照名字找,見欄號會因名冊而異。
前置
- 會開命令提示字元、會拖資料夾取得路徑 —— 不熟的先看第 0 課
- 電腦有 Python 3.7 以上(第 0 課有教怎麼確認)
- 知道自己的遊戲安裝資料夾在哪(裡面要有
data這個子資料夾)
零相依、預覽不改檔、只讀 models.big 不寫入、附完整原始碼。腳本授權 MIT(完整條款在原始碼檔尾),本頁教學文字採 CC BY 4.0。
👉 你要做的事
用上面的按鈕存檔。檔名必須正好是 mvp_swap_face.py。
瀏覽器多半不會問你要存哪裡,它會直接放進「下載」資料夾。 所以按完之後還有一步:
1打開檔案總管(Mac 用 Finder),左邊點「下載」
2找到那個 .py 檔
3把它拖到桌面
沒做這一步的話,後面 cd 到桌面再執行,
畫面會出現 can't open file —— 那不是你打錯,是檔案還在「下載」裡。
照第 0 課操作三把副檔名顯示出來,右鍵重新命名,刪掉結尾的 .txt。
Windows:
cd %USERPROFILE%\Desktop
Mac / Linux:
cd ~/Desktop
游標前那串字結尾變成 \Desktop>(Mac 是 Desktop %)。
確認腳本讀得到你的遊戲,順便看看你這份遊戲的臉皮狀況。
a先打這一段,最後那個空格打完就停住:
python mvp_swap_face.py
b把遊戲安裝資料夾拖進命令提示字元視窗(不是 data,是它的上一層),路徑會自動補上。
c按 Enter。
【總覽】共 2921 位球員
有專屬臉皮 (1-900) : 465 位 (15.9%)
用通用臉 (901 以上) : 2456 位 (84.1%)
沒有指定 (0) : 0 位
models.big 裡實際有 504 張臉皮
✅ 沒有球員指向不存在的臉皮
上面是剛安裝好、沒裝過任何模組的官方英文版跑出來的。 你裝過名冊或臉皮包的話,四個數字都會比這個大。
像這樣(這是一台裝過台灣模組的機器實際跑出來的):
【總覽】共 3247 位球員
有專屬臉皮 (1-900) : 712 位 (21.9%)
用通用臉 (901 以上) : 2510 位 (77.3%)
沒有指定 (0) : 25 位
models.big 裡實際有 894 張臉皮
⚠ 有 1 位球員指向不存在的臉皮:
David Phelps → c219(models.big 裡沒有這張)
代表你的 models.big 被換過(裝了臉皮包),但球員資料沒跟著更新。
這不影響這篇教學,遊戲通常會退回顯示通用臉。
⚠️ 本站 2026-08-28 訂正了這兩段
這兩段原本是對調的:正常那段印的是裝過模組那台的數字 (3247 位球員、894 張臉皮),結尾卻寫「✅ 沒有球員指向不存在的臉皮」。 拿同一份檔實際跑,出來的是警告那一段。
怎麼發現的:把腳本拿去跑剛安裝好的原版, 才看出那組數字根本不可能配那個結論 —— 有 25 位球員「沒有指定」臉皮的名冊,一定是被改過的。 範例輸出也要真的跑一次,不能照印象寫。
名字要用檔案裡的英文拼法,打姓或名都可以。
python mvp_swap_face.py "你的遊戲資料夾" --find Ohtani
找到 2 位:
識別碼 名 姓 face 狀態
0fff0f536 Shohei Ohtani 665 專屬臉 c665
0fff0f559 Shohei Ohtani 665 專屬臉 c665
同名的話,用識別碼指定比較保險:--set <識別碼> <編號>
為什麼同一個人出現兩次
投打二刀流的球員在資料裡佔兩列:一列是打者身分,一列是投手身分。 另外也真的有不同球員剛好同名的情況。
所以只要查出來不只一位,接下來一律用最左邊那串識別碼指定,不要用名字。
- 用英文拼法,不要打中文
- 只打姓試試看(
--find Ohtani而不是--find Shohei Ohtani) - 拼法不確定的話,先跑步驟 3 的總覽確認腳本讀得到檔案
把識別碼跟你想要的臉皮編號填進去。編號範圍 1 到 900。
python mvp_swap_face.py "你的遊戲資料夾" --set 0fff0f536 123
⚠ c123 目前已經有 1 位球員在用:
Kyle Schwarber
換下去不會出錯,但他們會跟你改的這位長同一張臉。
想避開的話,挑一個 models.big 裡有、名冊裡卻沒人用的編號。
c607 以上 EA 原版沒有指派給任何人,但剛安裝好的原版那一段
一張臉也沒有,指過去會被上面第一道檢查擋下,不能 --apply。
【預覽】Shohei Ohtani (識別碼 0fff0f536)
第 10 欄 playerattrib_face
原本:665 專屬臉 c665
改成:123 專屬臉 c123
這一列的位元組:262 → 262
以上只是預覽,沒有改到任何檔案。
確定要改的話,在同一行指令最後加上 --apply
最上面那段 ⚠ 只有在你挑的編號已經有人在用時才會出現
(上面這一份是本站測試機的名冊,c123 在它上面是 Kyle Schwarber 在用)。
它不會擋你:往下的【預覽】照樣印出來,加了 --apply 也照樣寫得進去,
只是那幾位會跟你改的這位長同一張臉。撞號不是錯誤。
腳本會擋下來不寫入。換一個確定存在的編號 ——
最保險的做法是抄別位球員的:用 --find 查一位你覺得長得順眼的球員,
把他的 face 數字拿來用。
你用了名字而不是識別碼。回步驟 4 把識別碼抄下來。
跟上一步同一行指令,只在最後多加 --apply。
腳本會先自動備份再寫入,寫完立刻複驗。
python mvp_swap_face.py "你的遊戲資料夾" --set 0fff0f536 123 --apply
已備份 → attrib.dat.facebak
已寫入。改動行數 = 1(應該是 1),第 10 欄現在是 123
✅ 複驗通過
它會自己拿剛才那份備份還原回去,畫面會印
「✅ 已自動還原,名冊回到你動手之前的樣子。」。
只有自動還原也不成功的時候,才需要你自己跑步驟 8 的還原指令
—— 那時候畫面會把整行指令印給你。兩種都以非 0 的結束碼收場,
不會靜靜地報成功。看到這個訊息就照最下面的範本回報。
開遊戲,找到那位球員的名單畫面或比賽畫面,看臉有沒有變。
- 改到另一列了 —— 二刀流球員有兩列,打者那列與投手那列各改各的。回步驟 4 看還有沒有另一個識別碼
- 遊戲有存檔快取 —— 試著開新的一季,而不是讀舊存檔
- 那張臉不存在 —— 回步驟 3 看總覽有沒有出現「指向不存在的臉皮」警告
從第一次 --apply 時建立的備份還原,還原完的檔案跟你動手之前逐位元組相同(本站實測 md5 前後一致)。
python mvp_swap_face.py "你的遊戲資料夾" --restore
已從備份還原 → attrib.dat
已還原,跟還原前有 1 行不同。
第二行的「有 1 行不同」比的是還原前那個被你改過的檔與還原後的檔,
代表剛才那次換臉真的被收回去了。本來就沒改過就直接 --restore 的話,
第二行會變成「跟還原前一模一樣 —— 你的檔案本來就跟備份相同,這次沒有任何改變。」
(2026-09-05 訂正:這一格原本印的是「位元組完全一致:✅ 是」。
那句話已經從腳本裡拿掉了 —— 它比的是剛複製過去的檔跟來源檔,
複製成功就必然相等,等於在報告自己剛複製成功。)
✅ 成功標準
以下全部滿足才算成功:
- 步驟 6 印出「改動行數 = 1」—— 如果不是 1,代表動到別的列了
- 步驟 6 印出「✅ 複驗通過」
- 遊戲資料夾裡多了一個
attrib.dat.facebak備份檔 attrib.dat的檔案大小跟改之前一樣(換成位數不同的編號時可能差幾個位元組,屬正常)- 進遊戲後那位球員的臉確實變了
- 如果臉沒變,先檢查
photo這一欄: 那位球員的photo如果是2, 他的臉皮一定是通用臉 —— 剛安裝好的原版裡 1,620 位photo=2的球員,臉皮 100% 落在 901–915,零例外。 改face不一定夠,photo也要跟著改。 (反過來不成立:photo=1的人也有六成多在用通用臉。 詳見球員欄位那頁)
❌ 出錯處理
| 畫面上的訊息 | 原因 | 你要做的 |
|---|---|---|
| can't open file '…\mvp_*.py' | 檔案還在「下載」資料夾,不在桌面。 這是本站最常見的第一個卡點。 | 開檔案總管左邊點「下載」,把那個 .py 拖到桌面,再跑一次。 |
| 找不到 …/attrib.dat | 拖到的資料夾不對 | 要拖遊戲安裝資料夾(它的下一層才是 data) |
| 開頭不像欄位表 | 檔案不是 attrib.dat,或已損壞 |
從遊戲備份還原,或重裝 |
| 沒有 CRLF 換行,可能已被編輯器改壞 | 之前用文字編輯器存過這個檔 | 換行符被改掉了,檔案已壞。從備份還原。以後不要用一般編輯器存這個檔 |
| 「X」對到 2 位球員 | 用名字指定但名字不唯一 | 改用識別碼 |
| models.big 裡沒有 cXXX 這張臉 | 編號不存在 | 加了 --apply 也不會寫入,不是印個警告就放行。換一個編號,或抄別位球員的 |
| 找不到 models.big,無法檢查臉皮編號是否存在 | 指到的資料夾底下沒有 data\models.big(印「讀 models.big 時出狀況」的也算沒讀到) |
總覽與 --find 照跑;但要換 1 到 900 的專屬臉又加了 --apply 時會停手不寫入。把遊戲安裝資料夾指對再跑一次 |
| ⚠ cXXX 目前已經有 N 位球員在用 | 你挑的臉皮編號已經有別的球員在用 | 這不是錯誤,不必理它,--apply 照樣寫得進去;只是那幾位會跟你改的這位長同一張臉。想避開就換一個沒人在用的編號 |
| 臉皮編號要在 0 到 999 之間 | 數字打錯 | 重打 |
| 找不到備份 attrib.dat.facebak | 還沒 --apply 過 | 本來就沒改過,不需要還原 |
| attrib.dat.facebak 是一個連結,而它指向的檔案不存在 | 那份備份是一個符號連結,而它指到的檔案已經不在了 —— 也就是連結斷了 | 先確認那個連結指到哪裡(Mac ls -l、Windows dir),或改用你自己另外留的那一份備份。腳本刻意不把它報成「找不到備份」,不然你會去找一個其實就躺在那裡的檔 |
| 要寫入的 attrib.dat 是一個符號連結 | 要寫進去的那個檔本身是符號連結(備份檔的話訊息裡寫的是 attrib.dat.facebak)—— 寫下去會動到連結指向的那個檔,不是你以為的那個 |
腳本停手不寫,一個位元組都沒動。先把那個連結刪掉或改個名字再跑一次 |
| 讀寫檔案時被作業系統擋下來了 | 遊戲裝在 C:\Program Files (x86)\ 底下,而命令提示字元不是用系統管理員身分開的;或那個檔正被遊戲、編輯器開著 |
對著命令提示字元按右鍵選「以系統管理員身分執行」,或先把遊戲關掉,再跑一次 |
| 備份 attrib.dat.facebak 沒有以換行收尾,它被截斷了 | 那份備份壞了(多半是備份途中被中斷:磁碟滿、外接碟拔掉、按了 Ctrl-C) | --restore 與 --apply 兩邊都會停手,遊戲檔一個位元組都不會被動到。改用你自己另外留的那一份整包備份 |
| 備份 … 只有 N 列球員資料,而現在的 attrib.dat 有 M 列 | 那份備份被截斷過,或者你在備份之後用別的工具加過球員 | 腳本停手不做,不會讓你少掉那幾列。真的確定要回到備份那個狀態的話,訊息裡附了 Windows copy /Y 與 Mac cp 的手動指令 |
| 複驗不通過 | 寫入結果與預期不符 | 腳本會自己拿剛才那份備份還原回去,印「✅ 已自動還原,名冊回到你動手之前的樣子。」;只有自動還原也不成功時才需要你自己跑 --restore(那時候畫面會把指令印給你)。兩種都以非 0 的結束碼收場。照下面的範本回報 |
一行就能還原
python mvp_swap_face.py "你的遊戲資料夾" --restore
備份是第一次 --apply 時建立的,
之後再改幾次都不會覆蓋那份最早的備份。所以永遠回得去原始狀態。
而且那份備份每次都會先被驗過:
再按一次 --apply 時,腳本會拿 --restore 的同一套把關去檢查既有的備份,
備份壞了就在寫入之前停手,不會讓你在沒有退路的狀態下改遊戲檔。
本站實測:把備份截成六成再跑 --apply,畫面印「備份 attrib.dat.facebak 沒有以換行收尾,它被截斷了」,
attrib.dat 的 md5 前後相同。
📋 卡住的話,照這個格式回報
作業系統:Windows 11 / Windows 10 / macOS ___
Python 版本:(python --version 印出來的那行)
卡在哪一步:步驟 ___
我打的指令:(整行複製貼上)
畫面印出什麼:(整段複製貼上)
步驟 3 的總覽印出幾位球員:___
attrib.dat 檔案大小:___ bytes
📋 回報範本
卡住或做成了,複製下面這段填一填:
換臉 status: ✅ / ❌
我改的球員: ___
臉皮編號從幾改成幾: ___ → ___
有沒有印「改動行數 = 1」: 有 / 沒有(印了幾?___)
有沒有出現備份檔: 有 / 沒有
進遊戲看起來有變嗎: 有 / 沒有 / 還沒看
畫面錯誤訊息(整段複製): ___
📌 重點整理
- 一句話 換臉只是改
attrib.dat(800 KB 純文字)裡playerattrib_face那一欄的數字,535.9 MB 的models.big完全不必改動(腳本只會唯讀它來核對編號)。欄號不要背:本站 2026-09-03 查過手邊九份內容不重複的名冊,這一欄九份都在第 10 欄,但那是運氣不是規定,腳本每次執行都自己從表頭反查。 - 編號怎麼分 1 到 900 對應
models.big裡的c###專屬臉,901 以上是通用臉。剛安裝好的原版共 2921 位球員,465 位有專屬臉(15.9%)、2456 位用通用臉(84.1%),models.big裡實際有 504 張臉皮。挑到別人已經在用的編號不會出錯,只是那幾位會跟你改的這位長同一張臉;EA 原版只用到 c606,c607 到 c900 沒有任何球員在用,但剛安裝好的原版那一段一張臉也沒有(504 張全落在c001–c606),指過去這支腳本會擋下--apply,而封裝檔不能新增編號、只能換掉現有的。要避開撞號,該挑的是「models.big裡有、名冊裡卻沒人用」的編號,兩份剛安裝好的原版量到的都是 39 個;c607 以上要能用,得是像本站測試機那份裝過模組的models.big(894 張臉,其中 294 張落在 c607–c900)。空位有幾個不能背,做法見〈做一張新的球員臉皮〉那一課的--info。 - 最容易踩的雷是同名 投打二刀流的球員在資料裡佔兩列(一列打者、一列投手),也真的有不同球員剛好同名。查出來不只一位,接下來一律用最左邊那串識別碼指定,不要用名字。
- 第二個雷是
photo欄 剛安裝好的原版裡 1,620 位photo=2的球員,臉皮 100% 落在 901 到 915,零例外。這種人光改face不一定夠,photo也要跟著改。反過來不成立,photo=1的人也有六成多在用通用臉。 - 不要用一般文字編輯器存這個檔
attrib.dat是 CRLF 換行,被編輯器存過換行符就被改掉了,腳本讀到會直接擋,畫面說「沒有 CRLF 換行,可能已被編輯器改壞」,那時只能從備份還原。 - 安全網
--set不加--apply就只是預覽(--restore是例外,它不吃--apply,備份過得了把關就直接蓋回去,本站實測);--apply會先確認你給的編號在不在models.big裡,換的是 1 到 900 的專屬臉時,編號不在、或整份models.big根本沒讀到,都是直接停手不寫入(901 以上是通用臉,本來就不在models.big裡,不受這一道擋),不是印個警告就放行;真的寫進去的那一次才會自動備份成attrib.dat.facebak,之後再改幾次都不會覆蓋那份最早的,而且每次--apply都會先把既有的那份備份驗過,備份壞了就在寫入之前停手;--restore一行回得去原始狀態,還原後位元組完全一致。 - 成功長什麼樣 畫面印出「改動行數 = 1」與「✅ 複驗通過」,遊戲資料夾裡多一個
attrib.dat.facebak。行數不是 1 代表動到別的列;複驗沒過腳本會自己拿備份還原回去(自動還原也失敗時才會請你自己跑--restore),兩種都以非 0 的結束碼收場,你只要照範本回報。 - 本站沒驗的 有球員指向不存在的臉皮時,這一頁寫的是遊戲「通常」會退回顯示通用臉,用的就是「通常」兩個字,沒有附任何實機數字;「臉沒變」時列的三個原因(改到二刀流的另一列、存檔快取、那張臉根本不存在)也是排查清單,同樣沒有數字。至於做一張遊戲裡本來沒有的新臉,那要動
models.big,不在這一頁的量測範圍,做法見〈做一張新的球員臉皮〉那一課。
想更進一步
- 做一張新的球員臉皮 —— 想指定的那張臉遊戲裡根本沒有?先用那一課把圖放進一個空編號,再回這一課把球員指過去
- 模型與臉皮 models.big —— 894 張臉皮的規格,以及為什麼七成七的球員共用 15 張臉
- 資料檔總覽 database ——
attrib.dat還有另外 45 個欄位 - 球員資料檔避雷 —— 改能力值之前一定要先讀
這支腳本在做什麼
沒讀過程式碼也看得懂的版本:它先把名冊讀進來,並確認那真的是名冊;
再從表頭問出 playerattrib_face 是第幾欄(不寫死,因為欄位順序會變);
接著把 models.big 的目錄讀一遍,好知道哪些臉皮編號真的存在。
然後才輪到你指定的那位球員:找到唯一的那一列、把那一格換掉、印出預覽。
到這裡為止,你的檔案一個位元組都還沒被動過。
只有在同一行加上 --apply,它才會先備份、再寫入、最後重讀一次複驗。
| 哪一段 | 做什麼 | 為什麼要有它 |
|---|---|---|
main() |
認參數、算出 attrib.dat 與 models.big 的位置、
決定 face 是第幾欄,然後把工作交給下面那幾個 cmd_*。 |
--restore 刻意排在讀名冊之前:
名冊已經壞到讀不動的時候,還原正是你現在要做的事,不該被「讀不動」擋在門外。 |
read_attrib() |
把 attrib.dat 整份讀成位元組,做三道檢查
(開頭是不是欄位表、有沒有 CRLF、行數夠不夠),再照 CRLF 切成一列一列。 |
刻意不解碼整份,寫回去時才能保證每個位元組都跟原來一樣。 CRLF 那一道擋的是「被一般文字編輯器存過」的檔。 |
resolve_field() |
從第一行表頭反查 playerattrib_face 是第幾欄。 |
欄位順序不是固定的。
本站比對過的兩份名冊,表頭都是 46 個具名欄位(編號 0 到 45)、
欄位名稱的集合也相同,順序卻不一樣:playerattrib_skin_tone
在剛安裝好的原版排第 12 欄、在本站測試機這份排第 38 欄,中間那 26 欄整批往前挪一格,
46 欄裡有 27 欄的編號對不上,
完整差異表在這一頁。
表頭拿逗號去切會切出 47 格,最後一格是收尾的 ; 不是欄位。
寫死欄號這件事,在本站另一支腳本上真的踩過。 |
big_entry_names()、available_faces() |
讀 models.big 的 16 位元組檔頭與目錄,
把 c001.fsh 這種名字換算成臉皮編號。 |
只讀目錄、不解壓、不寫入。但不是「一個位元組都不碰」:
目錄長度不固定,所以它會多讀一小段當餘量,那一小段會蓋到資料區的最前面。
本站測試機那份 models.big(561,891,312 位元組、目錄 4,211 項)總共讀進記憶體
273,616 位元組,而檔頭宣告資料區從 73,322 起算,等於有 200,294 位元組的資料區也進了記憶體;
剛安裝好的原版那份(172,992,803 位元組、目錄 2,671 項)是讀 175,056 對 46,780,多讀 128,276。
多讀的那一段不會被當成目錄項目解析,也沒有解壓、沒有寫回去,
所以「不必解壓、不會改到它」還是成立的。
有了這份清單,你打錯編號的時候才攔得下來:加了 --apply 也是直接停手不寫入,
不是印個警告就放行。一樣在測試機那份 models.big 上實測(它有 894 張臉皮,
c001 到 c900 之間缺 6 個號),拿它缺的 c116 加 --apply,
attrib.dat 的 md5 前後相同,連 attrib.dat.facebak 都不會產生。
清單根本沒讀到的時候擋得更早:編號落在 1 到 900 又加了 --apply,它連比對都不比就停手。 |
iter_players()、cell_value() |
逐列走名冊,取出識別碼、名、姓、face。 那一格開頭的欄位編號對不上,就回報「讀不出來」。 | 這個檔的設計是每一格自己帶著欄位編號。 對不上就不猜,寧可少報一位,也不要把別的欄位當成臉。 |
cmd_overview() |
不給任何選項時的預設動作:全名冊點一次名, 統計專屬臉、通用臉、沒指定,並列出指向不存在臉皮的人。 | 讀不到 models.big 的時候它只說「不知道」,不說「不存在」。
這兩件事混在一起,會讓一份好好的原版被報成幾百位球員的臉全壞了。 |
cmd_find()、find_target() |
查名字;真要改的時候先用識別碼精準比對,對不到才退回名字, 對到兩位以上一律停手。 | 投打二刀流的球員在名冊裡佔兩列,也真的有不同球員剛好同名。 猜錯就是改到另一個人,而你不會馬上發現。 |
build_new_line() |
整支腳本唯一產生新內容的地方:整列切成一格一格, 只換 face 那一格,再用逗號接回去。 | 其餘的空白、大小寫、位元組全部原封不動, 所以檔案大小幾乎不變(換成位數不同的編號時會差幾個位元組,屬正常)。 |
cmd_set() |
主流程:找人 → 檢查編號在不在 → 提醒撞號 → 印預覽 →
(要 --apply 才)備份 → 寫入 → 重讀複驗。 |
前面每一道都可能喊停,而喊停的時候檔案還沒被動過。 複驗要看到「改動行數 = 1」,多一行就代表動到別人。 |
_atomic_copy() |
先寫一個名字猜不到的暫存檔(tempfile.mkstemp,底層 O_CREAT|O_EXCL)、
fsync、讀回來逐位元組比對,全過了才換成正式名字。 |
中斷只會留下那個暫存檔, 不會留下一個「名字叫備份、其實是半截」的檔。半截的備份比沒有備份更危險。 名字猜不到又是獨佔開檔,還多擋一層:事先在旁邊放一個同名的符號連結想騙它去寫別的地方,佔不到位; 目的檔本身是符號連結的話就直接停手不寫。 |
looks_like_roster()、check_backup_usable()、_restore_from_backup() |
覆蓋之前的把關:三道問「這是不是名冊」(開頭是欄位表、檔案裡有 CRLF、表頭找得到 first_name 與 last_name),兩道問「它完不完整」(整份以 CRLF 收尾、最後一列的欄數跟第一列一樣),再加一道列數把關(備份的球員列數比現在這個檔少就停手)。--restore 與 --apply 走的是同一套,不會出現「改得進去、還原不回來」。 |
本站 2026-08-29 實測過:一份半截的備份會把 2.66 MB 的遊戲檔蓋成 300 KB,而畫面還印成功。 |
cmd_restore() |
把備份蓋回去,再比對「還原前」與「還原後」差了幾行。 | 讓你知道那一次還原到底有沒有意義。 本來就沒改過的話,它會直接說跟還原前一模一樣。 |
會動到你檔案的路只有兩條:--set … --apply 與 --restore。
其餘全程唯讀,models.big 從頭到尾只被讀目錄,沒有解壓也沒有寫入。
它不會幫你做一張遊戲裡本來沒有的臉,也不會跟著改
photo 欄,那兩件事各自在別的地方。
按 Ctrl-C 中斷時,它會照實際做到哪一步說話:換名之前中斷就是什麼都沒動;
換名之後才中斷,它會直接告訴你名冊已經被改了,並把 --restore 那一行印給你。
兩種都以結束碼 130 收場。
完整原始碼
下面就是你剛才下載的那一支,一個字都沒有不同(本站有自動檢查在守這件事)。不必下載也能先看過一遍。
展開 / 收合完整原始碼(1678 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_swap_face.py — 幫 MVP Baseball 2005 的球員換一張臉
這支腳本只動一個 800 KB 的純文字檔(data/database/attrib.dat),
完全不碰 536 MB 的 models.big。models.big 只會被「讀目錄」,不解壓、不寫入。
用法(先預覽,確定了再加 --apply):
python3 mvp_swap_face.py "<遊戲資料夾>"
python3 mvp_swap_face.py "<遊戲資料夾>" --find Ohtani
python3 mvp_swap_face.py "<遊戲資料夾>" --set <識別碼> 665
python3 mvp_swap_face.py "<遊戲資料夾>" --set <識別碼> 665 --apply
python3 mvp_swap_face.py "<遊戲資料夾>" --restore
python3 mvp_swap_face.py --selftest # 自我測試,不碰任何遊戲檔
--set 的第一個參數也可以直接打名字,但名字對到兩位以上就會停手要你改用識別碼:
投打二刀流的球員在名冊裡本來就佔兩列(一列打者、一列投手),Ohtani 就是這種。
識別碼是每一列最左邊那一串,用 --find 查得到。
零相依:只用 Python 3.7+ 內建功能,不必安裝任何東西。
自包含:整支腳本就是這一個檔,可以單獨複製出去用。
── 它到底在做什麼 ────────────────────────────────────────
遊戲怎麼知道某位球員長什麼臉?看名冊裡那一列的 playerattrib_face 欄。
本工具就只做一件事:把那一格的數字換掉,同一列其他的位元組原封不動。
吃什麼(輸入)
· 命令列第一個參數:遊戲安裝資料夾(它的下一層要有 data 這個子資料夾)
· data/database/attrib.dat:純文字名冊,CRLF 換行,每一格自己帶著欄位編號
· data/models.big:**只讀目錄**,拿來知道 c001…c### 這些臉皮哪些真的存在。
讀不到照樣能跑,總覽跟 --find 照印,只是不能幫你檢查編號存不存在;
那時候它會說「不知道」,不會把「不知道」報成「不存在」。
⚠️ 但「不知道」不等於放行:要換的是 1-900 的專屬臉編號又加了 --apply,
它會直接停手不寫入(畫面上是「無法確認,所以不寫入」)。
要寫得進去,得先讓它讀得到 models.big。
吐什麼(輸出)
· --find,以及沒加 --apply 的 --set:全部只印在畫面上,一個檔案都不動
· 加了 --apply:改寫 attrib.dat 的那一列,並在旁邊留下 attrib.dat.facebak
· --restore:把 attrib.dat.facebak 蓋回 attrib.dat
安全網(五層,由外而內)
1. 預設唯讀,但這一句只涵蓋 --set:不加 --apply 一律只做預覽。
--restore 不在這一句裡。它是另一條路,不看 --apply,備份通過覆蓋前那幾道把關之後,
就把 attrib.dat.facebak 整份蓋回 attrib.dat。本站把測試機那份 attrib.dat
(840,643 bytes)複製到別的資料夾實測,只下 --restore、沒有加 --apply,
那份複本的 MD5 當場變成備份的 MD5。
2. 寫之前先確認編號:要換的是 1-900 的專屬臉,而 models.big 裡沒有它、
或 models.big 根本沒讀到,加了 --apply 一律停手不寫入。預覽不擋,
讓你看完再決定;真的要動檔案才擋。
(本站測試機那份 models.big 有 894 張臉皮、c001 到 c900 之間缺 6 個號。
拿它缺的 c116 加 --apply 實測:attrib.dat 的 md5 前後相同,
連 attrib.dat.facebak 都不會產生)
3. 第一次真的寫入之前自動備份成 attrib.dat.facebak。備份是原子的
(先寫一個名字猜不到的暫存檔、fsync、讀回來逐位元組比對,全過了才 os.replace
換名),不會留下半截檔;之後再改幾次都不覆蓋
最早那一份,所以永遠回得去你動手之前的狀態。
旁邊已經有一份 attrib.dat.facebak 的時候,不是看到「存在」就放行:
那一份會先跑一次跟 --restore 同一套檢查,壞的話在寫入之前就停手。
(不然會變成「改得進去、還原不回來」—— 那正好是最糟的組合。)
4. 寫入也是原子的,而且暫存檔的名字猜不到(tempfile.mkstemp,底層 O_CREAT|O_EXCL):
事先在旁邊放一個同名的符號連結想騙它去寫別的地方,佔不到位;
**任何要寫的目的檔本身是符號連結一律停手** —— 名冊、備份、暫存檔都一樣,
不會跟著連結去改別的檔(路徑中間的資料夾是連結不擋,只看最後那一個檔;
讀取也不擋)。不會出現寫到一半的名冊。
換名(os.replace)跟「把換過了記下來」是同一段不可中斷的動作:
Ctrl-C 落在中間會被壓到那一段結束才丟出來,所以收尾訊息說的
「有沒有動到你的檔案」跟磁碟上的狀態永遠一致(exit code 130)。
5. 寫完立刻重讀複驗:行數要一樣、改動行數要剛好 1、那一格要真的是新值。
任何一項不符,它會**自己**拿剛才那份備份還原回去(退不回去才請你自己
下 --restore),而且一律以非 0 的 exit code 收場,不會靜靜地回報成功
另外 --restore 與 --apply 之前都會先驗那份備份,五道:開頭是欄位表、
檔案裡有 CRLF、表頭找得到 first_name 與 last_name、整份以 CRLF 收尾、
最後一列的欄數跟第一列一樣。後兩道專門對付截斷 —— 截在一列中間的話,
那一列的欄數一定湊不齊。(本站這台機器上找得到的 18 份 attrib.dat —— 含三份
剛安裝好的原版與 2009 年以來的社群名冊 —— 這五道全部通過,沒有一份被誤擋;
同一批檔各切兩種半截共 36 個,36 個都被擋下。)
截斷處剛好落在換行邊界上的話,這五道看不出來(那份 840,643 個位元組的名冊裡
有 3,248 個換行邊界)。所以還有第六道:備份的球員列數比現在這個檔少就停手 ——
還原不可以讓名冊變短。反過來(備份比較長)照做,因為那是
「壞掉的是現在這個檔、備份來救它」。
這六道 --restore 與 --apply 跑的是同一支函式,所以「改得進去」與
「還原得回來」永遠是同一個答案,不會改完才發現退不回去。
做不到的事(先說在前面)
· **不會做新的臉皮。** models.big 全程唯讀,連解壓都沒有。想放一張遊戲裡
本來沒有的臉,那是另一課的事。
· **不會幫你改 photo 欄。** 本站量到剛安裝好的原版裡 photo=2 的球員,
臉皮 100% 落在 901-915,那種球員光改 face 不一定看得出差別。
· 不保證「換完在遊戲裡好看」。本工具只保證那個欄位被改成你指定的數字,
畫面上長什麼樣本站沒驗。
· 一次只改一列。這是刻意的:複驗那句「改動行數 = 1」才有意義。
· 不會替你判斷編號選得好不好。撞到別人只會提醒一句,照樣寫得進去
(抄別位球員的編號本來就是正常做法)。但「models.big 裡沒有這張」跟
「models.big 沒讀到」不只是提醒:編號落在 1-900 又加了 --apply,
一律停手不寫入。
授權:MIT(見檔尾完整條款)。本站教學文字另採 CC BY 4.0。
"""
#
# ─────────────────────────────────────────────────────────
# 法律與免責(每一支本站腳本都帶著這一段)
#
# · 本工具與 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 stat
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 只看開頭,看不出後面少了多少。
#
# ── 2026-09-05 第二輪唯讀稽核抓到的:暫存檔的名字可以被預先佔位 ──────────
# 原本寫的是 part = dst + '.part',名字完全猜得到;而 shutil.copy2() 會
# **跟著符號連結走**。有人(或別的程式)先在旁邊放一個 attrib.dat.facebak.part
# 指向資料夾外面的某個檔,那個檔會在 os.replace() 之前就被開起來截成 0 再寫入 ——
# os.replace() 事後只換掉連結本身,但外面那個檔早就沒了。
# 現在改成 tempfile.mkstemp(dir=目的檔那個資料夾):名字帶隨機字尾,而且底層是
# O_CREAT|O_EXCL 開的 —— 已經存在的名字一律開不起來,佔不到位。
# 目的檔自己也要查:是符號連結就停手不寫(見 _link_guard)。
# ⚠️ Path.exists() / os.path.exists() 對「指向不存在目標的符號連結」會回 False,
# 所以那道要用 os.path.islink / os.lstat,不能用 exists 代替。
# 「換名」做過沒有 —— 每成功一次 os.replace 就往這裡記一筆 (種類, 路徑)。
# 為什麼要記:Ctrl-C 的收尾訊息原本無條件印「沒有改到任何檔案」,可是中斷點
# 如果落在 os.replace 之後(複驗、印字都還在後面),那句話就是假的 ——
# 使用者以為什麼都沒發生,實際上名冊已經被換掉了。
# 種類有三種:'backup'(只產生了 .facebak)、'set'(名冊被改了)、
# 'restore'(名冊已經被備份蓋回去了)。
_REPLACED = []
# ── 2026-09-06 第三輪唯讀稽核抓到的:Ctrl-C 落在「換名」與「登記」之間 ──────
# 上面那份 _REPLACED 是換名之後才記的,而「os.replace 這一行」跟「append 這一行」
# 是兩個獨立的動作。Ctrl-C 剛好落在中間的話,收尾訊息看到的還是舊狀態,
# 於是印出「沒有改到任何檔案」—— 名冊其實已經被換掉了。窗口很窄,但它是真的。
#
# 兩道一起上:
# (a) _NoInterrupt 把「換名 + 登記」包成一段不可中斷的動作。這段期間收到的
# SIGINT 先記著,離開這段之後再照常丟出 —— 所以收尾看到的登記
# 一定跟磁碟上的狀態一致。
# (b) _INFLIGHT 是保險:進入那段之前先登記「正在換 X」。(a) 失效的時候
# (例如不在主執行緒,signal.signal 裝不上去)還有這一層,
# 收尾會說「中斷時正在替換 X」,不會說「沒動到」。
_INFLIGHT = []
class _NoInterrupt(object):
"""把「os.replace + 登記」包起來:這段期間收到 Ctrl-C 先記著,離開這段之後再丟。
這樣 KeyboardInterrupt 的收尾看到的 _REPLACED 一定跟磁碟上的狀態一致。
裝不上處理器的情況(不在主執行緒等)就退回原本行為 —— 不會比以前更糟,
而且還有 _INFLIGHT 那一層保險。
"""
def __enter__(self):
self._pending = False
self._old = None
try:
self._old = signal.signal(signal.SIGINT, self._remember)
except (ValueError, OSError):
self._old = None
return self
def _remember(self, signum, frame):
self._pending = True
def __exit__(self, exc_type, exc, tb):
if self._old is not None:
try:
signal.signal(signal.SIGINT, self._old)
except (ValueError, OSError):
pass
if self._pending and exc_type is None:
raise KeyboardInterrupt
return False
def _replace_and_record(tmp, dst, kind):
"""換名,然後登記 —— 兩件事之間不接受中斷。
三態:進來之前是「還沒動」,_INFLIGHT 有東西是「正在換」,
_REPLACED 有東西是「已經換過」。收尾訊息就照這三態說話。
"""
_INFLIGHT.append((kind, dst))
try:
with _NoInterrupt():
os.replace(tmp, dst) # os.replace 是原子的
_REPLACED.append((kind, dst))
except Exception:
# os.replace 自己失敗(磁碟滿、跨裝置、權限):它要嘛整個成功要嘛完全沒動,
# 所以走到這裡可以確定沒換成,「正在換」撤掉是安全的。
# ⚠️ 這裡刻意只接 Exception 不接 BaseException:KeyboardInterrupt 走到這裡
# 的時候「換成了沒有」是不確定的,那一筆就要留在 _INFLIGHT 裡讓收尾說實話。
_INFLIGHT.pop()
raise
_INFLIGHT.pop()
def _link_guard(p, what):
"""目的檔是符號連結(或 Windows 的 reparse point)就停手,不寫。
換名用的 os.replace 只會換掉連結本身,看起來很安全;但**寫之前**那個開檔
動作會跟著連結走,先把連結指到的那個檔截斷。所以要擋在開檔之前。
(父資料夾是連結不擋 —— 外接碟、mod 管理器做的目錄連結都很正常。
暫存檔一律開在目的檔解析過後的真實資料夾裡,所以照樣落在同一個位置。)
"""
p = os.fspath(p)
try:
st = os.lstat(p) # lstat 不跟著連結走,dangling 也看得到
except OSError:
return # 不存在就沒有連結問題
is_link = os.path.islink(p)
# Windows 的 junction / reparse point:os.path.islink() 不見得認得。
if not is_link and hasattr(st, 'st_file_attributes'):
is_link = bool(st.st_file_attributes
& getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0))
if is_link:
raise DataError(
'%s 是一個符號連結:\n'
' %s\n'
' 寫進去會動到連結指向的那個檔,不是你以為的那個 —— 所以停手,\n'
' 一個位元組都沒有動。請先把那個連結刪掉(或改個名字)再跑一次。'
% (what, p))
def _open_tmp_beside(dst, tag):
"""在 dst 真正所在的那個資料夾裡開一個獨佔的暫存檔,回傳 (fd, 路徑)。
名字猜不到(mkstemp 會加隨機字尾),而且底層用 O_CREAT|O_EXCL ——
事先放一個同名的連結在那裡也佔不到位,開檔會直接失敗。
跟目的檔同一個資料夾是 os.replace 換名要能原子完成的前提。
"""
dst = os.fspath(dst)
d = os.path.dirname(os.path.realpath(dst)) or '.'
return tempfile.mkstemp(dir=d, prefix='.' + os.path.basename(dst) + '.' + tag + '-')
def _sha256(p):
"""整個檔的 SHA-256(分段讀,不把整份塞進記憶體)。"""
h = hashlib.sha256()
with open(os.fspath(p), 'rb') as f:
for chunk in iter(lambda: f.read(1 << 20), b''):
h.update(chunk)
return h.hexdigest()
def _atomic_copy(src, dst, tag='part', kind='backup'):
"""把 src 的內容原子地放到 dst —— 要嘛完整、要嘛 dst 原封不動。
五步,少一步都不算原子:
1. 開一個**猜不到名字**的獨佔暫存檔(同一個資料夾)
2. 寫完 flush + os.fsync,確定內容真的落到磁碟才往下走
3. 把權限帶過去(目的檔本來就在的話以它為準)
4. 讀回來算 SHA-256,跟來源逐位元組對過
5. 都通過才 os.replace 換名 —— 而且換名與登記綁在一起(_replace_and_record),
中間不接受 Ctrl-C,收尾訊息才不會說「沒動到」。
任何一步失敗就把暫存檔收掉,dst 這個名字上不會出現半截的檔。
⚠️ 本站有些腳本用 pathlib.Path 存路徑,有些用字串。
2026-08-29 第一版寫成 dst + '.part',在 Path 上直接 TypeError,
等於所有備份都失敗 —— 而且「半截備份被擋下來」那個測試照樣是綠的。
是陰性對照(先證明正常流程真的會產生備份)抓到的。
"""
src, dst = os.fspath(src), os.fspath(dst)
_link_guard(dst, '要寫入的 %s' % os.path.basename(dst))
fd, tmp = _open_tmp_beside(dst, tag)
try:
h = hashlib.sha256()
# fdopen 寫在前面:萬一 open(src) 失敗,fd 也會被關掉,不會漏。
with os.fdopen(fd, 'wb') as fout, open(src, 'rb') as fin:
for chunk in iter(lambda: fin.read(1 << 20), b''):
h.update(chunk)
fout.write(chunk)
fout.flush()
os.fsync(fout.fileno()) # 換名之前先確定內容真的落到磁碟
# 權限與時間:先照來源那一份(還原時 = 備份,備份時 = 遊戲檔),
# 目的檔本來就在的話再以它為準 —— 使用者刻意設成唯讀(444)保護的檔
# 不該因為被還原過一次就把那道鎖弄丟。
try:
shutil.copystat(src, tmp)
except OSError:
pass
if os.path.exists(dst):
try:
shutil.copymode(dst, tmp)
except OSError:
pass
# 複驗:寫下去的東西要跟來源逐位元組相同,對不上就不換名。
# (2026-08-29 那次 300 KB 蓋掉 2.66 MB,就是因為沒有人回頭讀一次。)
if _sha256(tmp) != h.hexdigest():
raise DataError(
'寫出來的內容跟來源對不上,所以不換名 —— %s 一個位元組都沒被動到。'
% os.path.basename(dst))
# 換名 + 登記是同一段不可中斷的動作(理由見檔案上面 _replace_and_record)。
_replace_and_record(tmp, dst, kind)
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,443,584 → 680,448
(它還印「複驗:內容與備份相同 ✅」)
最後那個會讓遊戲**完全開不起來**,而站上每一課都寫著「隨時可以 --restore」。
現在檢查五件事:
1. 備份不是 0 bytes
2. BIGF:檔頭第 4-8 個位元組宣告的總長度要等於實際長度
(兩種位元組序都接受;哪些檔是大端、各有幾個,以 reference/bigf.html 量到的為準,這裡不寫會過期的數字)
3. LOCH(語系檔):檔頭指到的 LOCL 要在檔內,而且最後一條字串的位移
也要在檔內 —— 截斷之後那個位移一定會超出去
4. MZ(執行檔):PE 節區表裡 raw offset + raw size 的最大值不得超過檔案長度
5. **通用地板**:非 BIGF 的備份不得小於「要被蓋掉的那個檔」的一半。
非 BIGF 的工具都是原地改(大小幾乎不變),所以這條很安全;
BIGF 走 append 會越改越大,所以刻意**不套**這條,由第 2 道負責。
"""
import struct
bak, dst = os.fspath(bak), os.fspath(dst)
# ⚠️ 這裡不能用 os.path.exists:備份如果是一個指向不存在目標的符號連結,
# exists() 會回 False,錯誤訊息就會變成「找不到備份」,把真正的問題
# (那是一個壞掉的連結)藏起來。lexists 看得到連結本身。
if not os.path.lexists(bak):
raise SystemExit('找不到備份:%s' % bak)
if not os.path.exists(bak):
raise SystemExit('備份 %s 是一個連結,而它指向的檔案不存在 —— 不敢拿它還原。' % bak)
# 第 1 道:0 bytes 的備份一定是壞的,不必看內容就能判定。
n = os.path.getsize(bak)
if n == 0:
raise SystemExit(
'備份是 0 bytes(多半是上次備份到一半被中斷),不敢拿它覆蓋 %s。' % dst)
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 個位元組寫著「整個檔應該多長」,
# 跟實際長度對不上就是被截斷了。小端與大端兩種都接受:
# 本機 295 個 BIGF 檔裡 288 個小端、7 個大端,兩種都是真的。
if len(head) == 8 and head[:4] == b'BIGF':
le = struct.unpack('<I', head[4:8])[0]
be = struct.unpack('>I', head[4:8])[0]
if le != n and be != n:
_stop('檔頭說它應該是 %d bytes(或 %d),實際只有 %d bytes。' % (le, be, n))
return _do_copy(bak, dst)
# 第 3 道:語系檔。位移 16-20 指向字串區 LOCL,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 道:Windows 執行檔。位移 0x3C 是 PE 檔頭的位置,PE 檔頭後面是節區表,
# 每個節區 40 bytes,其中位移 16-24 是「這一節在檔案裡的長度與起點」。
# 所有節區的結尾取最大值,那就是這個執行檔至少該有多長。
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 走到這裡
# 「不到一半」是門檻,不是證明:它擋得住明顯的半截檔,擋不住只少了幾百
# 位元組的。真正保得住你的,還是你自己另外留的那一整包備份。
# (用 exists 是對的:這一道比的是「要被蓋掉的那個檔有多大」,
# 檔案不在就沒有東西要比。dst 是不是連結由 _atomic_copy 的 _link_guard 擋。)
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)
# 上面那幾道都沒喊停才會走到這裡。整支腳本真正「用備份蓋掉正本」的動作只有這裡。
# ⚠️ 這裡原本是直接 shutil.copy2(bak, dst)。複製途中被中斷(磁碟滿、外接碟拔掉、
# 按了 Ctrl-C)會在 attrib.dat 這個名字上留下一個半截的名冊 —— 備份寫得那麼小心,
# 還原卻不是原子的,等於前面那幾道白做。改成走 _atomic_copy:
# 先寫一個名字猜不到的暫存檔(同一個資料夾)、fsync、逐位元組複驗,
# 全過了才 os.replace 換名。中斷只會留下那個暫存檔,attrib.dat 完好。
# (_atomic_copy 會把備份的權限與修改時間帶回去。)
def _do_copy(bak, dst):
# 登記('restore')由 _replace_and_record 在換名的同一段裡做掉,這裡不另外記 ——
# 分開記就會又長出一個「換完了還沒記」的窗口,那正是這一輪要關掉的東西。
_atomic_copy(bak, dst, tag='restore', kind='restore')
# Windows 的命令提示字元預設用 cp950,直接印中文會炸掉。
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except Exception:
pass
# ⚠️ 2026-08-25:欄位編號不寫死。
# 不同來源的名冊,欄位排列順序不一樣 —— 拿剛安裝好的原版跟裝過台灣模組的
# 那台比,兩份的表頭都是 46 個具名欄位(編號 0 到 45)、名稱集合也相同,
# 但順序不同:playerattrib_skin_tone 在原版是第 12 欄、在這台是第 38 欄,
# 中間那 26 欄整批往前挪一格,46 欄裡有 27 欄的編號對不上。
# (拿逗號去切表頭會切出 47 格,最後一格是收尾的 ';',不是欄位。)
# playerattrib_face 剛好兩邊都在第 10 欄,但那是運氣不是設計,
# 所以改成執行時從表頭找。(同一個問題在 mvp_edit_speed.py 上真的踩到了。)
FACE_NAME = 'playerattrib_face'
FACE_FIELD = None # 由 resolve_field() 在執行時決定
# 名與姓的欄位編號。這兩格只拿來「找人」跟「印給你看」,寫入時碰都不碰,
# 所以不像 face 那樣需要每次從表頭反查。
NAME_FIRST = 0
NAME_LAST = 1
GENERIC_MIN = 901 # 901 以上是通用臉,不對應 models.big 的 c###
# 目錄項目數的理智上限。BIGF 檔頭那個數字若被改壞(或那根本不是 BIGF),
# 拿它去乘就會要求配置天文數字的記憶體。這是「明顯不合理就停手」的門檻,
# 不是格式規定的最大值。
MAX_BIG_ENTRIES = 100000
class DataError(Exception):
"""檔案不存在或格式不符預期。訊息是給人看的。"""
# ─────────────────────────────────────────────────────────
# BIG 封裝檔:只讀目錄,不解壓
# ─────────────────────────────────────────────────────────
def big_entry_names(path):
"""回傳 .big 目錄裡的所有項目名稱。只讀檔頭與目錄,外加目錄後面一小段餘量,不解壓也不寫入。"""
# BIGF 的檔頭固定 16 個位元組:
# 0-4 magic,就是 'BIGF' 四個字母
# 4-8 整個檔案的長度
# 8-12 目錄裡有幾個項目(**大端序**,EA 在這個欄位不用小端)
# 12-16 資料區的起點
# 底下只讀檔頭與目錄,不解壓也不寫入 models.big。
# 但目錄長度不固定,所以會多讀一小段當餘量,這一小段會蓋到資料區的最前面。
# 本站測試機那份 models.big(561,891,312 位元組,目錄 4,211 項)總共讀進記憶體
# 273,616 位元組,而檔頭宣告資料區從 73,322 起算,等於有 200,294 位元組的資料區
# 也進了記憶體;剛安裝好的原版那份(172,992,803 位元組,目錄 2,671 項)是讀 175,056、
# 資料區起點 46,780,多讀 128,276。
# 多讀的那一段不會被當成目錄項目解析:兩份都在資料區起點之前就走完 count 項。
# 所以「不解壓、不會改到它」成立,但「一個位元組都不碰」照字面講是誇大的。
try:
with open(path, 'rb') as f:
head = f.read(16)
if len(head) < 16 or head[:4] != b'BIGF':
raise DataError('%s 的開頭不是 BIGF,這不是封裝檔' % os.path.basename(path))
count = int.from_bytes(head[8:12], 'big')
if not 0 < count < MAX_BIG_ENTRIES:
raise DataError('%s 的目錄項目數異常(%d)' % (os.path.basename(path), count))
# 目錄長度不固定(名稱是變長字串),保守多讀一些再逐項解析
blob = head + f.read(count * 64 + 4096)
except OSError as e:
raise DataError('讀不到 %s:%s' % (path, e))
# 目錄的每一項 = 4 bytes 位移 + 4 bytes 長度 + 一個 0x00 收尾的名字。
# 名字是變長的,所以「第 N 項在哪裡」沒辦法直接算,只能從頭一項一項走。
names = []
pos = 16
for i in range(count):
if pos + 8 > len(blob):
break # 目錄比預估長,已讀到的就夠用
pos += 8 # 跳過 offset 與 size
end = blob.find(b'\x00', pos)
if end < 0:
break
names.append(blob[pos:end].decode('latin-1', 'replace'))
pos = end + 1
return names
# 只認「c + 剛好三位數字 + .fsh」這種名字。models.big 裡還有球衣、球棒之類的
# 東西,不長這樣的一律不算臉皮。寧可少認,不要把不是臉的東西算進來。
def available_faces(models_big):
"""models.big 裡實際存在的臉皮編號集合(c001.fsh → 1)。"""
faces = set()
for n in big_entry_names(models_big):
m = re.fullmatch(r'c(\d{3})\.fsh', n)
if m:
faces.add(int(m.group(1)))
return faces
# ─────────────────────────────────────────────────────────
# attrib.dat:純文字,但每一格都帶欄位編號
# ─────────────────────────────────────────────────────────
# 這個函式會擋下四種狀況,但第一種跟後面三種不是同一回事:讀不到這個檔
# (路徑拖錯、檔名打錯)走的是下面那個 OSError 分支;剩下三道才是格式檢查,
# 依序是開頭像不像欄位表(不像多半是拿到了別的檔)、有沒有 CRLF(這個檔被
# 一般文字編輯器存過,換行被換成 LF)、行數夠不夠(連表頭加一列球員都湊不齊
# 的檔,不值得再往下猜)。CRLF 那一道本站沒驗遊戲會怎麼反應,但檔案已經跟
# 原本不同了,所以一律擋下來不動它。
def read_attrib(path):
"""回傳 (原始 bytes, [每列的 bytes])。刻意不解碼整份,寫回時才能保證 byte 一致。"""
try:
raw = open(path, 'rb').read()
except OSError as e:
raise DataError('讀不到 %s:%s' % (path, e))
if not raw[:1].isdigit():
raise DataError('%s 的開頭不像欄位表,可能不是 attrib.dat' % os.path.basename(path))
if b'\r\n' not in raw:
raise DataError('%s 沒有 CRLF 換行,可能已被編輯器改壞' % os.path.basename(path))
lines = raw.split(b'\r\n')
if len(lines) < 3:
raise DataError('%s 只有 %d 行,內容不完整' % (os.path.basename(path), len(lines)))
return raw, lines
# 名冊的第一行是表頭,長成「0 first_name,1 last_name,…」這樣:
# 每一格自己帶著欄位編號。所以欄位順序可以不一樣,編號才是身分。
def header_names(line_bytes):
"""表頭:{欄位編號: 欄位名}"""
out = {}
for c in line_bytes.split(b','):
m = re.match(rb'^\s*(\d+) (\w+)', c)
if m:
out[int(m.group(1))] = m.group(2).decode('latin-1')
return out
# 從表頭反查欄位編號,而不是寫死。理由見檔案上面那段 ⚠️:
# 兩份名冊的表頭都是 46 個具名欄位、欄位名稱的集合也相同,順序卻不一樣。
# (下面錯誤訊息印的「它有 %d 個欄位」數的就是這 46 個具名欄位。)
def resolve_field(lines, wanted_name):
"""從表頭找出某個欄位真正的編號。找不到就明白說,不要猜。"""
names = header_names(lines[0])
for num, nm in names.items():
if nm == wanted_name:
return num
raise DataError(
'這份名冊的表頭裡找不到 %s 這個欄位。\n'
' 它有 %d 個欄位。這支腳本不敢在看不懂的檔案上動手。'
% (wanted_name, len(names)))
# 取一格的值。回傳 None 有兩種意思:這一列沒有那麼多格、或那一格開頭的欄位
# 編號跟預期對不上。兩種都當成「讀不出來」:寧可少報,也不要猜。
def cell_value(line_bytes, field):
"""取第 field 欄的值。資料列行首多一個識別碼,所以位置是 field+1。"""
cells = line_bytes.split(b',')
idx = field + 1
if idx >= len(cells):
return None
m = re.match(rb'^(\d+) ?(.*)$', cells[idx].strip())
if not m or int(m.group(1)) != field:
return None # 欄位編號對不上就不猜
return m.group(2).decode('latin-1')
# 每一列開頭那一格就是識別碼,是這份名冊裡的唯一身分。
# 同名球員只能靠它區分,所以 --set 建議一律用它而不是名字。
def row_id(line_bytes):
return line_bytes.split(b',')[0].decode('latin-1')
# 逐列走整份名冊。名或姓讀不出來就跳過:那不是一列正常的球員資料,
# 硬解只會把統計數字弄髒。
def iter_players(lines):
"""逐列產出 (行號, row-id, 名, 姓, face)。跳過表頭與空行。"""
for i, l in enumerate(lines):
if i == 0 or not l.strip():
continue
first = cell_value(l, NAME_FIRST)
last = cell_value(l, NAME_LAST)
face = cell_value(l, FACE_FIELD)
if first is None or last is None:
continue
yield i, row_id(l), first, last, face
# ─────────────────────────────────────────────────────────
# 各種動作
# ─────────────────────────────────────────────────────────
# 沒給 --find 也沒給 --set 時的預設動作:整份名冊點一次名,回答
# 「幾位有專屬臉、幾位用通用臉、有沒有人指向不存在的臉」。全程唯讀。
def cmd_overview(lines, faces):
custom = generic = zero = broken = 0
dangling = []
for _, rid, first, last, face in iter_players(lines):
if face is None or not face.isdigit():
broken += 1
continue
v = int(face)
if v == 0:
zero += 1
elif v >= GENERIC_MIN:
generic += 1
else:
custom += 1
if faces is not None and v not in faces:
dangling.append((first, last, v))
# 四個桶子互斥,所以總數直接相加就好,不另外數一次,
# 免得兩個數字對不起來的時候不知道該信哪一個。
total = custom + generic + zero + broken
print('\n【總覽】共 %d 位球員' % total)
print(' 有專屬臉皮 (1-%d) : %5d 位 (%.1f%%)' % (GENERIC_MIN - 1, custom, pct(custom, total)))
print(' 用通用臉 (%d 以上) : %5d 位 (%.1f%%)' % (GENERIC_MIN, generic, pct(generic, total)))
print(' 沒有指定 (0) : %5d 位' % zero)
if broken:
print(' 讀不出 face 欄 : %5d 位' % broken)
# ⚠️ 「讀不到 models.big」跟「讀到了、但裡面沒有這張臉」是兩件事。
# 這裡原本混在一起:讀不到時 faces 是空集合,於是每一位有專屬臉的
# 球員都被算成「指向不存在的臉皮」,還附一句「你的 models.big 被換過」。
# 拿剛安裝好的原版跑,會被告知 465 位球員的臉全壞了 —— 而它其實好好的。
if faces is None:
print('\n models.big 沒讀到,所以「不知道」裡面有幾張臉。')
print(' 下面只報名冊自己的統計,不判斷臉皮存不存在。')
return
print('\n models.big 裡實際有 %d 張臉皮' % len(faces))
if dangling:
print('\n ⚠ 有 %d 位球員指向不存在的臉皮:' % len(dangling))
for first, last, v in dangling[:10]:
print(' %s %s → c%03d(models.big 裡沒有這張)' % (first, last, v))
if len(dangling) > 10:
print(' …另外還有 %d 位' % (len(dangling) - 10))
print(' 這通常代表你的 models.big 被換過,而球員資料沒跟著更新。')
else:
print('\n ✅ 沒有球員指向不存在的臉皮')
# --find:查名字。名跟姓都比對,一律轉小寫再比。最多印 40 位,
# 印不完時提示「名字打完整一點」,比一次噴一千行有用。
def cmd_find(lines, faces, keyword):
kw = keyword.lower()
hits = [(i, rid, f, l, face) for i, rid, f, l, face in iter_players(lines)
if kw in f.lower() or kw in l.lower()]
if not hits:
print('\n找不到名字含「%s」的球員。' % keyword)
print('提示:名字要用檔案裡的英文拼法,例如 Ohtani 而不是「大谷」。')
return
print('\n找到 %d 位:' % len(hits))
print(' %-10s %-14s %-14s %-8s %s' % ('識別碼', '名', '姓', 'face', '狀態'))
for i, rid, first, last, face in hits[:40]:
print(' %-10s %-14s %-14s %-8s %s' % (rid, first, last, face or '?', face_status(face, faces)))
if len(hits) > 40:
print(' …另外還有 %d 位(名字打完整一點可以縮小範圍)' % (len(hits) - 40))
if len(hits) > 1:
print('\n 同名的話,用識別碼指定比較保險:--set <識別碼> <編號>')
# 把一個 face 值翻成人看得懂的一句話。faces 是 None 的時候只說「無法確認」,
# 絕不說「不存在」。這兩件事在這支腳本裡從頭到尾都分得很開。
def face_status(face, faces):
if face is None or not face.isdigit():
return '讀不出來'
v = int(face)
if v == 0:
return '沒有指定'
if v >= GENERIC_MIN:
return '通用臉'
if faces is None:
return '專屬臉 c%03d(沒讀到 models.big,無法確認)' % v
return '專屬臉 c%03d' % v if v in faces else '⚠ 指向不存在的 c%03d' % v
# 百分比。分母是 0 的時候回 0,不讓一份空名冊變成除以零的錯誤訊息。
def pct(a, b):
return a / b * 100 if b else 0.0
# 先用識別碼精準比對,對不到才退回用名字。名字對到兩位以上一律停手:
# 投打二刀流的球員在名冊裡本來就佔兩列(一列打者、一列投手),
# 也真的有不同球員剛好同名,猜錯就是改到另一個人。
def find_target(lines, who):
"""who 可以是識別碼或名字。回傳唯一那一列,不唯一就報錯。"""
exact = [(i, rid, f, l, face) for i, rid, f, l, face in iter_players(lines) if rid == who]
if len(exact) == 1:
return exact[0]
kw = who.lower()
hits = [(i, rid, f, l, face) for i, rid, f, l, face in iter_players(lines)
if kw == f.lower() or kw == l.lower() or kw == ('%s %s' % (f, l)).lower()]
if not hits:
raise DataError('找不到「%s」。先用 --find %s 查查看正確拼法。' % (who, who))
if len(hits) > 1:
msg = ['「%s」對到 %d 位球員,請改用識別碼指定:' % (who, len(hits))]
for i, rid, f, l, face in hits[:10]:
msg.append(' %s %s %s (目前 face=%s)' % (rid, f, l, face))
raise DataError('\n'.join(msg))
return hits[0]
# 整支腳本唯一會產生新內容的地方。做法是把整列切成一格一格,只換其中一格,
# 再用逗號接回去,其餘的空白、大小寫、位元組全部原封不動。
# 換之前先確認那一格真的以「<欄位編號> 」開頭,對不上就寧可不動。
def build_new_line(line_bytes, new_face):
"""把 face 欄(FACE_FIELD,執行時從表頭決定)換成新值,其餘 byte 原封不動。"""
cells = line_bytes.split(b',')
idx = FACE_FIELD + 1
if idx >= len(cells):
raise DataError('這一列沒有第 %d 欄,不敢動' % FACE_FIELD)
old = cells[idx]
m = re.match(rb'^(\s*)(\d+) ?(.*)$', old)
if not m or int(m.group(2)) != FACE_FIELD:
raise DataError('第 %d 欄的內容是 %r,不是預期的「%d <值>」格式,不敢動'
% (FACE_FIELD, old, FACE_FIELD))
cells[idx] = b'%s%d %d' % (m.group(1), FACE_FIELD, new_face)
return b','.join(cells)
# --set:換臉的主流程。順序是刻意排的:
# 找人 → 檢查編號在不在 models.big 裡 → 提醒撞號 → 印預覽
# →(要 --apply 才)備份 → 原子寫入 → 重讀複驗。
# 前面每一道都可能喊停,而喊停的時候檔案一個位元組都還沒被動過。
def cmd_set(path, raw, lines, faces, who, new_face, apply_it):
i, rid, first, last, face = find_target(lines, who)
# 第一道:你給的這個編號到底存不存在?三種情況分開處理:
# 讀不到 models.big(不知道)、讀到了但裡面沒有這張(不存在)、有這張(放行)。
# 前兩種都會先警告;只有加了 --apply 才真的擋下來,預覽一律讓你看完。
if faces is None and new_face != 0 and new_face < GENERIC_MIN:
print('\n⚠ models.big 沒讀到,所以無法確認 c%03d 這張臉在不在。' % new_face)
if apply_it:
raise DataError('無法確認,所以不寫入。先確認遊戲資料夾路徑對不對。')
elif faces is not None and new_face != 0 and new_face < GENERIC_MIN and new_face not in faces:
print('\n⚠ models.big 裡沒有 c%03d 這張臉。' % new_face)
print(' 換下去遊戲可能顯示成通用臉或出問題。')
print(' 可用的編號範圍:c%03d – c%03d(共 %d 張)'
% (min(faces), max(faces), len(faces)) if faces else ' (讀不到可用編號)')
if apply_it:
raise DataError('為安全起見不寫入。確定要用這個編號的話,先確認 models.big 有它。')
# ── 撞號提醒 ────────────────────────────────────────────
# 臉皮編號在 EA 原版只用到 c606:兩份剛安裝好的原版(英文版與中文版,models.big
# 都是 172,992,803 bytes)量到的 504 張臉全部落在 c001-c606,c607-c900 是 0 張。
# 所以那一段的意思是「沒人占用」,不是「有臉可以換」。上面第一道檢查會擋下
# models.big 裡沒有的編號,所以在剛安裝好的原版上拿 c700 去 --apply 會直接被拒絕。
# 裝過模組的就不一定:本站測試機那份 models.big 有 894 張,c607-c900 占了 294 張。
# 拿一個「已經有別人在用」的編號來換,不會出錯,但那個人的臉就跟你一樣了。
# 2009 年那份台灣之光模組用了 15 個 EA 範圍內的編號,其中 14 個撞號 ——
# 他們的做法是把被撞到的現役球員改成通用臉,退休球員直接不放進名冊。
# 那是有人一個一個決定的。這裡只負責讓你知道有這回事。
if new_face != 0 and new_face < GENERIC_MIN:
others = []
for j, ln in enumerate(lines[1:], start=1):
if j == i or not ln.strip():
continue
v = cell_value(ln, FACE_FIELD)
if v and v.strip().isdigit() and int(v) == new_face:
others.append('%s %s' % (cell_value(ln, NAME_FIRST) or '?', cell_value(ln, NAME_LAST) or '?'))
if others:
print('\n⚠ c%03d 目前已經有 %d 位球員在用:' % (new_face, len(others)))
for o in others[:5]:
print(' %s' % o.strip())
if len(others) > 5:
print(' …另外還有 %d 位' % (len(others) - 5))
print(' 換下去不會出錯,但他們會跟你改的這位長同一張臉。')
print(' 想避開的話,挑一個 models.big 裡有、名冊裡卻沒人用的編號。')
print(' c607 以上 EA 原版沒有指派給任何人,但剛安裝好的原版那一段')
print(' 一張臉也沒有,指過去會被上面第一道檢查擋下,不能 --apply。')
# 到這一行為止,檔案一個位元組都還沒動。下面先把新的那一列做出來,
# 目的只是印預覽給你看,沒有 --apply 就到此為止。
old_line = lines[i]
new_line = build_new_line(old_line, new_face)
print('\n【預覽】%s %s (識別碼 %s)' % (first, last, rid))
print(' 第 %d 欄 playerattrib_face' % FACE_FIELD)
print(' 原本:%s %s' % (face, face_status(face, faces)))
print(' 改成:%-5d %s' % (new_face, face_status(str(new_face), faces)))
if old_line == new_line:
print('\n 這一列已經是這個值了,不需要改動。')
return
print('\n 這一列的位元組:%d → %d' % (len(old_line), len(new_line)))
if not apply_it:
print('\n 以上只是預覽,沒有改到任何檔案。')
print(' 確定要改的話,在同一行指令最後加上 --apply')
return
# 這一道要擋在**備份之前**:名冊本身是符號連結的話,連備份都不該產生 ——
# 產生了只會讓人以為「有備份、應該有動到什麼」。
_link_guard(path, '要寫入的 %s' % os.path.basename(path))
# 只有第一次 --apply 會建立備份,之後再改幾次都保留最早那一份。
# 「回得去的」要是你動手之前的原始狀態,不是上一次改完的狀態。
backup = path + '.facebak'
if not os.path.lexists(backup):
# 登記('backup')在 _replace_and_record 裡跟換名綁在一起,這裡不另外記。
_atomic_copy(path, backup, kind='backup')
print('\n 已備份 → %s' % os.path.basename(backup))
else:
# ⚠️ 既有的備份也要驗過才能信。只看「存不存在」的話,旁邊躺著一份
# 0 bytes 或半截的 .facebak 會讓這裡直接放行 —— 遊戲檔改下去了,
# 之後 --restore 又拒收那份備份,使用者沒有退路。
# 這裡跑的就是 --restore 那一支把關函式,所以「改得進去」與
# 「還原得回來」永遠是同一個答案。
check_backup_usable(backup, path, '這次不寫入')
print('\n 備份已存在,保留最早那一份 → %s' % os.path.basename(backup))
new_lines = list(lines)
new_lines[i] = new_line
out = b'\r\n'.join(new_lines)
# 先整份寫到一個暫存檔,再用 os.replace 換名。擋得住的是「腳本中途被打斷」
# (按 Ctrl-C、磁碟滿、外接碟被拔):那時候留下來的是那個暫存檔,attrib.dat 完好。
# ⚠️ 暫存檔的名字**不可以**是 path + '.tmp' 這種猜得到的:事先在旁邊放一個
# 同名的符號連結指向資料夾外面,open(..., 'wb') 會跟著它走,把外面那個檔
# 截成 0 —— 而 os.replace 之後只換掉連結,外面那個檔已經回不來了。
# mkstemp 的名字帶隨機字尾又是 O_CREAT|O_EXCL 開的,佔不到位。
# 目的檔自己是連結的話一樣停手(上面建備份之前就查過了)。
# 換名之前 fsync:內容確定落到磁碟才換名。(換名這個動作本身在斷電下的
# 持久性仍由檔案系統決定,本站沒有辦法對「拔電源」給保證。)
fd, tmp = _open_tmp_beside(path, 'tmp')
try:
with os.fdopen(fd, 'wb') as f:
f.write(out)
f.flush()
os.fsync(f.fileno())
# 換名之前先把原本那個檔的權限套到新檔上。新建的檔套的是行程的 umask,
# 不補這一步的話,600 會變成 644、使用者刻意設成唯讀(444)保護的名冊
# 會在改完之後連那道鎖都被拿掉。備份那邊也有帶,這樣兩邊才一致。
# (Windows 上只有唯讀那一位有意義,套過去不會出事。)
try:
shutil.copymode(path, tmp)
except OSError:
pass # 權限套不回去不該讓整次寫入失敗,內容才是重點
# 換名之前讀回來對一次:磁碟寫滿、外接碟出狀況的時候,write() 不見得會叫。
# 對不上就不換名,attrib.dat 一個位元組都不會被動到。
# (跟備份那邊走同一個 _sha256,兩處的「讀回來比對」是同一個出口。)
if _sha256(tmp) != hashlib.sha256(out).hexdigest():
raise DataError(
'寫出來的內容跟預期對不上,所以不換名 —— %s 一個位元組都沒被動到。'
% os.path.basename(path))
# ⚠️ 換名與登記中間不可以留縫。原本是 os.replace 一行、_REPLACED.append
# 另一行,Ctrl-C 落在中間的話收尾會照舊狀態印「沒有改到任何檔案」,
# 而名冊已經被換掉了。現在兩件事包在同一段不可中斷的動作裡。
_replace_and_record(tmp, path, 'set')
except BaseException:
try:
os.remove(tmp) # 失敗就把半截的暫存檔收掉,不留垃圾
except OSError:
pass
raise
# 複驗:重讀一次,確認真的改成功而且沒動到別的地方。
# 三件事一起查:讀得回來、行數沒變、改動行數剛好 1 而且那一格真的是新值。
# ⚠️ 三道以前是三個各自 raise 的 if,只印紅字叫使用者自己去 --restore。
# 現在改成先收成一個 problem,再由同一段收尾處理 —— 因為「該還原」的
# 判斷三道是一樣的,分開寫就會像 2026-09-05 之前那樣:行數那一道漏掉了
# 自動還原,只有欄位那一道有。
problem = None
lines2 = None
try:
_raw2, lines2 = read_attrib(path)
except DataError as e:
problem = '寫入後的名冊讀不回來(%s)' % e
if problem is None and len(lines2) != len(lines):
problem = '寫入後行數變了(%d → %d)' % (len(lines), len(lines2))
if problem is None:
diff = sum(1 for a, b in zip(lines, lines2) if a != b)
got = cell_value(lines2[i], FACE_FIELD)
print(' 已寫入。改動行數 = %d(應該是 1),第 %d 欄現在是 %s'
% (diff, FACE_FIELD, got))
if diff != 1 or got != str(new_face):
problem = '改動行數 %d(應該是 1)、第 %d 欄是 %s' % (diff, FACE_FIELD, got)
if problem is not None:
# ⚠️ 複驗不過不可以只印一行紅字就算了。這時候名冊已經被換過名,
# 而旁邊那份備份是**進來之前就驗過**的(上面 check_backup_usable 那一段),
# 所以直接拿它退回去是安全的,不必等使用者自己想到要下 --restore。
# 退不回去才把指令印出來請他自己來。無論哪一種都是非 0 離開。
print(' ❌ 複驗不通過(%s),正在用剛才那份備份自動還原……' % problem)
try:
# ⚠️ 這裡**不能**呼叫 check_backup_usable:它比的是「備份 vs 現在這個檔」,
# 而現在這個檔正是剛剛寫壞的那一份 —— 寫壞的方式如果是多長出幾列,
# 那道就會判「還原會少掉幾列」而拒絕,把使用者鎖在壞掉的名冊上。
# 要比的對象是**動手之前**那一份(raw),所以這裡自己比一次。
looks_like_roster(backup, os.path.basename(path))
n_bak, n_before = _roster_rows(open(backup, 'rb').read()), _roster_rows(raw)
if n_bak < n_before:
raise DataError('備份只有 %d 列,動手之前的名冊有 %d 列,不敢拿它蓋回去。'
% (n_bak, n_before))
_restore_from_backup(backup, path)
print(' ✅ 已自動還原,名冊回到你動手之前的樣子。')
except (DataError, SystemExit, OSError) as e:
print(' ⚠ 自動還原沒有成功:%s' % e)
print(' 請自己執行下面這一行(把路徑換成你的遊戲資料夾):')
print(' python3 %s "<遊戲資料夾>" --restore'
% os.path.basename(sys.argv[0] or 'mvp_swap_face.py'))
raise DataError('複驗不通過(%s),已如上處理。' % problem)
print(' ✅ 複驗通過')
# 數名冊裡有幾列球員資料(表頭與空行不算)。這個數字拿來比對「備份」與
# 「現在這個檔」—— 截斷剛好落在換行邊界上的時候,列數是唯一還看得出來的痕跡。
def _roster_rows(raw):
return sum(1 for l in raw.split(b'\r\n')[1:] if l.strip())
# 備份的把關。--restore 與 --apply 兩條路都會先跑這一支,
# 再交給 _restore_from_backup 做通用的那幾道(0 bytes、大小地板)。
# 這一支查的是「名冊自己的格式」,那一支查的是「所有檔案都適用的常識」。
def looks_like_roster(backup, target_name):
"""覆蓋之前先確認這個備份真的是名冊,而且沒有被截斷。
⚠️ 本站另外三支工具的備份就躺在同一個資料夾,副檔名各不相同但都在旁邊。
路徑打錯、或有人手動改過副檔名,就會拿別的東西蓋掉名冊。
這道把關查的是「檔案裡真的有的東西」,五件:
開頭是數字欄位表、檔案裡有 CRLF、表頭找得到 first_name 與 last_name、
整份以 CRLF 收尾、最後一列的欄數跟第一列一樣。
前三件在問「這是不是名冊」,後兩件在問「它完不完整」。
"""
try:
raw = open(backup, 'rb').read()
except OSError as e:
raise DataError('讀不到備份 %s:%s' % (os.path.basename(backup), e))
if not raw[:1].isdigit():
raise DataError('備份 %s 的開頭不像欄位表,不敢拿它覆蓋 %s。'
% (os.path.basename(backup), target_name))
if b'\r\n' not in raw:
raise DataError('備份 %s 沒有 CRLF 換行,可能已被編輯器改壞,不敢覆蓋。'
% os.path.basename(backup))
header = raw.split(b'\r\n', 1)[0].decode('latin-1', 'replace')
if 'first_name' not in header or 'last_name' not in header:
raise DataError('備份 %s 的表頭找不到 first_name / last_name,不像名冊,不敢覆蓋。'
% os.path.basename(backup))
# ── 下面兩道專門對付「被截斷」──────────────────────────────
# 上面三道只看開頭,開頭再漂亮也看不出後面少了多少 —— 2026-08-29 那份
# 300 KB 蓋掉 2.66 MB 的實測就是這樣過關的。這兩道改看檔案的結尾:
# (a) 完整的名冊一定以 CRLF 收尾
# (b) 每一列的欄數都一樣,截在列中間的話最後一列一定湊不齊
# 本站這台機器上找得到的 18 份 attrib.dat(含三份剛安裝好的原版與 2009 年
# 以來的社群名冊)全部符合這兩條:都以 CRLF 收尾、資料列一律 48 格。
# ⚠️ 擋不住的那一種:截斷處剛好落在換行邊界上。那份 840,643 個位元組的名冊裡
# 有 3,248 個換行邊界,所以「剛好切在邊界」不是不可能,只是少見。
# 那一種交給 check_backup_usable 那一道(備份的列數比現在這個檔少就停手)。
lines = raw.split(b'\r\n')
if lines[-1] != b'':
raise DataError('備份 %s 沒有以換行收尾,它被截斷了,不敢拿它覆蓋 %s。\n'
' 多半是備份途中被中斷(磁碟滿、外接碟拔掉、按了 Ctrl-C)。\n'
' 請改用你自己另外留的那一份備份。'
% (os.path.basename(backup), target_name))
rows = [l for l in lines[1:] if l.strip()]
if len(rows) < 2:
raise DataError('備份 %s 只有 %d 列球員資料,內容不完整,不敢覆蓋 %s。'
% (os.path.basename(backup), len(rows), target_name))
first_cells, last_cells = len(rows[0].split(b',')), len(rows[-1].split(b','))
if first_cells != last_cells:
raise DataError('備份 %s 的最後一列只有 %d 格,前面每一列都是 %d 格 ——\n'
' 它被截斷在一列的中間,不敢拿它覆蓋 %s。\n'
' 請改用你自己另外留的那一份備份。'
% (os.path.basename(backup), last_cells, first_cells, target_name))
# --apply 與 --restore 共用的備份把關。
# ⚠️ 兩邊查的必須是同一套,不然就會出現「改得進去、還原不回來」——
# 那是最糟的組合:遊戲檔已經被改了,而唯一的退路被自己的檢查拒收。
# (2026-09-05 覆驗時真的踩到:先只在 --restore 那一邊加了列數這一道,
# 於是一份剛好截在換行邊界上的備份,--apply 放行、--restore 拒收。)
# 回傳 (備份的列數, 現在這個檔的列數);現在這個檔不在就回 (備份的列數, None)。
def check_backup_usable(backup, path, action):
looks_like_roster(backup, os.path.basename(path))
n_bak = _roster_rows(open(backup, 'rb').read())
if not os.path.exists(path):
return n_bak, None
n_now = _roster_rows(open(path, 'rb').read())
# 列數是「剛好截在換行邊界上」唯一還看得出來的痕跡。方向不對稱,只擋一邊:
# · 備份比較短 → 拿它還原會讓名冊變短,停。
# · 備份比較長 → 那是壞掉的是現在這個檔、備份來救它,放行。
# (備份之後用別的工具加過球員也會落在第一種。那時候還原本來就會把那些
# 球員一起收回去,所以停下來問一聲是對的 —— 訊息裡有手動做的指令。)
if n_bak < n_now:
raise DataError(
'備份 %s 只有 %d 列球員資料,而現在的 %s 有 %d 列 ——\n'
' 用它還原會少掉 %d 列,所以%s。\n'
' 兩種可能:這份備份被截斷過,或者你在備份之後用別的工具加過球員。\n'
' 真的確定要回到備份那個狀態的話,自己複製一次就好:\n'
' Windows: copy /Y "%s" "%s"\n'
' Mac: cp "%s" "%s"'
% (os.path.basename(backup), n_bak, os.path.basename(path), n_now,
n_now - n_bak, action, backup, path, backup, path))
return n_bak, n_now
# --restore:把關都過了才覆蓋(是不是名冊、有沒有被截斷、列數會不會變少、
# 是不是 0 bytes、有沒有小到不到正本的一半)。覆蓋完再把「還原前」跟「還原後」比一次,
# 告訴你到底變了幾行,這樣你才知道剛剛那次還原有沒有意義。
def cmd_restore(path):
backup = path + '.facebak'
if not os.path.exists(backup):
raise DataError('找不到備份 %s。這支腳本只在第一次 --apply 時建立備份。'
% os.path.basename(backup))
n_bak, n_now = check_backup_usable(backup, path, '停手不做')
if n_now is not None and n_bak > n_now:
print('\n 註:備份有 %d 列球員資料,現在這個檔只有 %d 列。' % (n_bak, n_now))
print(' 現在這個檔比備份短,還原會把少掉的那些列補回來。')
# ⚠️ 這裡原本印「位元組完全一致」,拿的是剛複製過去的檔跟來源比 ——
# 複製成功就一定相等,那句話只是在報告自己剛複製成功。
before = open(path, 'rb').read() if os.path.exists(path) else None
_restore_from_backup(backup, path)
after = open(path, 'rb').read()
print('\n 已從備份還原 → %s' % os.path.basename(path))
if before is None:
print(' (還原前那個檔不在,所以沒有可比對的對象)')
elif before == after:
print(' 跟還原前一模一樣 —— 你的檔案本來就跟備份相同,這次沒有任何改變。')
else:
lb, la = before.split(b'\r\n'), after.split(b'\r\n')
# ⚠️ zip() 只比到短的那一邊為止。行數不一樣的時候多出來的那幾千列
# 一列都不會被算進去 —— 「備份少了 1300 列」會被報成「只差 1 行」,
# 而那正好是最需要被看見的那一種。所以行數差要另外加回去。
n = sum(1 for x, y in zip(lb, la) if x != y) + abs(len(lb) - len(la))
print(' 已還原,跟還原前有 %d 行不同。' % n)
if len(lb) != len(la):
print(' ⚠ 名冊的總行數從 %d 變成 %d。' % (len(lb), len(la)))
print(' 上面那個「有幾行不同」已經把行數差算進去了。')
print(' 走到這裡代表備份比還原前那個檔長 —— 短的那個方向')
print(' 在覆蓋之前就被擋掉了,所以你沒有掉任何一列。')
# ─────────────────────────────────────────────────────────
# 進入點。這裡只做四件事:認參數、算出兩個檔案的位置、決定 face 是第幾欄,
# 然後把工作交給上面那幾個 cmd_*。真正會寫檔的只有 cmd_set 與 cmd_restore。
def main():
ap = argparse.ArgumentParser(
description='幫 MVP Baseball 2005 的球員換一張臉(只動 attrib.dat 純文字檔)')
ap.add_argument('gamedir', nargs='?',
help='遊戲資料夾(裡面要有 data 這個子資料夾)')
ap.add_argument('--find', metavar='名字', help='查球員目前用哪張臉')
ap.add_argument('--set', nargs=2, metavar=('球員', '臉皮編號'),
help='把某位球員的臉換成指定編號')
ap.add_argument('--apply', action='store_true', help='真的寫入(沒加就只是預覽)')
ap.add_argument('--restore', action='store_true', help='從備份還原 attrib.dat')
ap.add_argument('--selftest', action='store_true',
help='自我測試,不碰任何遊戲檔')
args = ap.parse_args()
# --selftest 排在最前面,因為它自己造測試資料,不需要遊戲資料夾。
if args.selftest:
return selftest()
if not args.gamedir:
ap.print_help()
return 2
# 使用者給的是「遊戲安裝資料夾」,這兩個檔案在它底下的相對位置是固定的。
attrib = os.path.join(args.gamedir, 'data', 'database', 'attrib.dat')
models = os.path.join(args.gamedir, 'data', 'models.big')
# attrib.dat 有可能是一個符號連結(有人把名冊放在別處,或用模組管理器
# 做連結共用)。
# ⚠️ 2026-09-06 訂正:這裡原本是「解成真實路徑再照常寫」。那樣做的問題是
# 你以為在改 A,實際被改的是 B —— 而畫面上只有一行小字提過。本站的規矩
# 改成:要「寫」的那個檔本身是連結,一律停手,由你自己決定要動哪一個。
# (路徑中間的**資料夾**是連結不擋 —— 遊戲裝在別顆碟很正常,
# 只看最後那一個檔。讀取也不擋。)
if os.path.islink(attrib):
print('❌ %s 是一個符號連結:' % os.path.basename(attrib))
print(' %s' % attrib)
print(' 本工具不跟著連結寫 —— 會被改到的是連結指向的那個檔,不是你以為的那個。')
print(' 請把那個連結換成真正的檔案(或直接把遊戲資料夾指到連結的目的地)再跑一次。')
return 2
# --restore 要排在「找不到 attrib.dat」之前:名冊被刪掉或改名的時候,
# 還原正是你現在要做的事,不該被「找不到」擋在門外 —— 備份就躺在旁邊。
# (排在讀名冊之前也是同一個理由:名冊壞到讀不動時,還原不該被「讀不動」擋住。)
if args.restore:
bakpath = attrib + '.facebak'
# ⚠️ 這兩種要分開講。os.path.isfile / exists 對「指向不存在目標的符號連結」
# 都會回 False,一律報成「找不到備份」的話,使用者會照著去找一個
# 其實就躺在那裡的檔 —— 真正的問題是那個連結斷了。
if os.path.lexists(bakpath) and not os.path.exists(bakpath):
print('❌ %s 是一個連結,而它指向的檔案不存在。' % bakpath)
print(' 先確認那個連結指到哪裡(Mac:ls -l / Windows:dir),')
print(' 或改用你自己另外留的那一份備份。')
return 2
if not os.path.isfile(bakpath):
print('❌ 找不到 %s' % bakpath)
print(' 請確認第一個參數是遊戲資料夾(裡面應該要有 data 這個子資料夾)。')
print(' 這支腳本只在第一次 --apply 時建立備份。')
return 2
cmd_restore(attrib)
return 0
if not os.path.isfile(attrib):
print('❌ 找不到 %s' % attrib)
print(' 請確認第一個參數是遊戲資料夾(裡面應該要有 data 這個子資料夾)。')
return 2
raw, lines = read_attrib(attrib)
# face 是第幾欄要執行時才知道(見檔案上面那段 ⚠️),決定不了就 DataError 停下來:
# 看不懂的名冊寧可不動。不是第 10 欄時會印一行出來,讓你知道這份名冊不一樣。
global FACE_FIELD
FACE_FIELD = resolve_field(lines, FACE_NAME)
if FACE_FIELD != 10:
print(' 註:這份名冊的 %s 在第 %d 欄。' % (FACE_NAME, FACE_FIELD))
# 讀 models.big 只是為了「能不能幫你檢查編號」,不是必要條件。
# 讀不到就繼續跑,但要把這件事說出來。
# None = 沒讀到(不知道);set() = 讀到了而且真的是空的。
# 兩者不可以混用 —— 混用就會把「不知道」報成「不存在」。
faces = None
if os.path.isfile(models):
try:
faces = available_faces(models)
except DataError as e:
print('⚠ 讀 models.big 時出狀況:%s' % e)
print(' 仍可繼續,但無法檢查臉皮編號是否存在。')
else:
print('⚠ 找不到 models.big,無法檢查臉皮編號是否存在。')
# 三種動作互斥,都沒給就印總覽。總覽與 --find 全程唯讀,只有 --set 會寫檔。
if args.find:
cmd_find(lines, faces, args.find)
elif args.set:
who, num = args.set
if not num.lstrip('-').isdigit():
print('❌ 臉皮編號要是數字,你給的是「%s」' % num)
return 2
# 範圍檢查放在這裡而不是 cmd_set:參數打錯屬於「使用者輸入錯誤」,
# 回傳 exit code 2;檔案內容不對才是 DataError(exit code 1)。
# 0-999 是因為臉皮編號是 c### 三位數,0 代表「不指定」。
n = int(num)
if n < 0 or n > 999:
print('❌ 臉皮編號要在 0 到 999 之間,你給的是 %d' % n)
return 2
cmd_set(attrib, raw, lines, faces, who, n, args.apply)
else:
cmd_overview(lines, faces)
return 0
def _fake_roster(rows=3, face='665'):
"""造一份最小但格式正確的名冊(46 個具名欄位 + 收尾的 ';',CRLF 換行)。
完全不碰遊戲檔:--selftest 只在系統暫存區工作。
"""
names = {0: 'first_name', 1: 'last_name', 10: 'playerattrib_face'}
hdr = ','.join('%d %s' % (n, names.get(n, 'playerattrib_x%d' % n))
for n in range(46)) + ',;'
out = [hdr]
for k in range(rows):
cells = []
for n in range(46):
if n == 0:
v = 'Test%d' % k
elif n == 1:
v = 'Player%d' % k
elif n == 10:
v = face if k == 0 else '901'
else:
v = str(n)
cells.append('%d %s' % (n, v))
out.append('id%08d,' % k + ','.join(cells) + ',;')
return ('\r\n'.join(out) + '\r\n').encode('latin-1')
def selftest():
"""--selftest:自己造一份最小的名冊來測,完全不碰遊戲檔。
測的是這支工具對讀者的承諾,一條一條驗。**正向與反向都要有** ——
2026-08-29 本站踩過「防線壞了測試照樣全綠」,所以每一道守門都先證明
「正常流程真的做得到」,再放一個餌證明「答案錯的時候它真的會叫」。
正向 9 道:
· 造出來的名冊讀得出來、face 欄是第 10 欄
· 沒加 --apply 一個位元組都不動、不產生備份、不記到任何一次換名
· --apply 之後那一格真的是新值
· 沒點名的那幾列逐位元組相同
· 備份逐位元組等於動手之前的名冊
· 換名有被記下來(Ctrl-C 的收尾訊息靠它)
· --restore 之後逐位元組回到動手之前
(--selftest 不接受 python -O:-O 會把下面每一個 assert 拿掉,
整份測試會在什麼都沒驗的情況下印「全部通過」。進來第一行就擋掉。)
反向餌 13 組(2026-09-05 第二輪、2026-09-06 第三輪稽核補的守門,每一道各配一組):
A 事先把「備份名 + .part」放成指向資料夾外面的符號連結 → 跑 --apply,
外面那個檔必須一個位元組都沒變(暫存檔的名字已經猜不到了)
B 事先把「名冊名 + .tmp」放成同樣的連結 → 同上
C 寫入的目的地本身是符號連結 → 停手,連結沒被換掉、外面那個檔沒被動到
C2 旁邊躺著一份指向垃圾內容的既有 .facebak → 停手,名冊不可以被改
D 名冊本身是符號連結 → 停手,而且連結指到的那份名冊沒被動到
E 還原做到一半失敗(把 os.replace 換成一定丟例外的版本)→
正本原封不動,而且不留下暫存檔
F 複驗的「改動行數 = 1」那一道 → 自己還原回去、丟出例外
F2 複驗的「行數沒變」那一道(只在列尾多一個換行,內容全都一樣)→ 同上
G 備份是一個指向不存在目標的符號連結 → 不可以被報成「找不到備份」
G2 同一件事在命令列那條路(main() 的 --restore)上也要成立
H 「換名之前讀回來比一次」那一道 → 備份與寫入兩條路都要停手,不留暫存檔
I _NoInterrupt 真的把 Ctrl-C 壓到區塊結束才丟(而且處理器有還回去)
J 換名剛做完就中斷 → 登記一定已經在了,收尾不可以說「沒動到」;
連 (a) 失效時由 _INFLIGHT 接手的「正在替換」那一句也一起驗
K 名冊本身是符號連結 → 命令列那條路(main())一律停手、exit code 2,
連結沒被換掉、它指到的那份名冊沒被動到、不產生備份
每一組都拿「把對應的那道守門改壞」驗過會亮 —— 餌會自己失效而沒有人發現,
所以餌本身也要驗。(2026-09-05 那 10 組是 14 個突變體;2026-09-06 補的
I / J / K 三組另外驗過:拆掉 _NoInterrupt、把登記搬回換名之後、
把 main() 改回「解成真實路徑再照常寫」,三個變體各自被對應的餌咬到。)
"""
# ⚠️ 這一道要排在最前面。python -O 會把 assert 整個拿掉,下面幾十道守門
# 一句都不會執行,而畫面照樣印「全部通過」—— 那是最糟的一種綠燈。
if sys.flags.optimize:
print('--selftest 不能在 python -O 下跑:-O 會把 assert 全部拿掉,測試會假綠')
return 2
import io as _io
import contextlib
global FACE_FIELD
def _raise_sigint():
"""對自己發一次 SIGINT(等於使用者按了 Ctrl-C)。"""
if hasattr(signal, 'raise_signal'): # 3.8+
signal.raise_signal(signal.SIGINT)
else:
os.kill(os.getpid(), signal.SIGINT)
d = tempfile.mkdtemp(prefix='mvp_swap_face_selftest-')
db = os.path.join(d, 'data', 'database')
os.makedirs(db)
p = os.path.join(db, 'attrib.dat')
bak = p + '.facebak'
blob = _fake_roster()
open(p, 'wb').write(blob)
outside = os.path.join(d, 'OUTSIDE.txt') # 「資料夾外面那個檔」的替身
guard_bytes = b'do not touch me\r\n' * 100
faces = {665, 700}
def _reload():
return read_attrib(p)
def _quiet(fn, *a, **kw):
with contextlib.redirect_stdout(_io.StringIO()):
return fn(*a, **kw)
raw, lines = _reload()
FACE_FIELD = resolve_field(lines, FACE_NAME)
assert FACE_FIELD == 10, '自造名冊的 face 欄應該是第 10 欄,測試本身壞了'
# 正向:沒加 --apply 一個位元組都不動
_quiet(cmd_set, p, raw, lines, faces, 'id00000000', 700, False)
assert open(p, 'rb').read() == blob, '預覽竟然動到檔案'
assert not os.path.lexists(bak), '預覽不該產生備份'
assert not _REPLACED, '預覽不該記到任何一次換名'
# ── 餌 A/B:事先佔位的可預測暫存檔名 ────────────────────────────
# 舊寫法是 dst + '.part' 與 path + '.tmp',名字完全猜得到,而開檔會跟著
# 符號連結走。這裡先把兩個名字都佔掉,指向資料夾外面那個檔。
open(outside, 'wb').write(guard_bytes)
for lure in (bak + '.part', p + '.tmp'):
os.symlink(outside, lure)
raw, lines = _reload()
_quiet(cmd_set, p, raw, lines, faces, 'id00000000', 700, True)
assert open(outside, 'rb').read() == guard_bytes, \
'餌 A/B:資料夾外面那個檔被寫到了 —— 暫存檔還是走可預測的名字'
for lure in (bak + '.part', p + '.tmp'):
assert os.path.islink(lure), '餌 A/B:那兩個連結不該被換掉'
os.remove(lure)
# 正向:--apply 真的改到那一格,而且只改那一列;備份逐位元組等於動手之前
_raw3, lines3 = _reload()
assert cell_value(lines3[1], FACE_FIELD) == '700', '--apply 之後那一格不是新值'
assert lines3[2:] == lines[2:], '沒點名的那幾列被動到了'
assert open(bak, 'rb').read() == blob, '備份跟動手之前不是逐位元組相同'
assert ('set', p) in _REPLACED, 'os.replace 做過了卻沒記下來 —— Ctrl-C 會說錯話'
# 正向:--restore 逐位元組回到動手之前
_quiet(cmd_restore, p)
assert open(p, 'rb').read() == blob, '還原之後應該逐位元組回到原本的樣子'
# ── 餌 C:寫入的目的地本身是符號連結 ────────────────────────
# 直接叫 _atomic_copy —— 這是整支腳本所有「產生檔案」的共同出口。
# (旁邊已經有一個 .facebak 連結的情況走不到這裡:那時候腳本只會**讀**
# 那份備份、由名冊那幾道決定收不收,不會往它身上寫。所以要驗守門本身,
# 就得對著出口驗。)
os.remove(bak)
open(outside, 'wb').write(guard_bytes)
link_dst = os.path.join(db, 'link-target.dat')
os.symlink(outside, link_dst)
try:
_atomic_copy(p, link_dst)
except DataError:
pass
else:
raise AssertionError('餌 C:目的地是符號連結竟然照寫 —— 防線失效')
assert os.path.islink(link_dst), '餌 C:那個連結被換成一般檔案了'
assert open(outside, 'rb').read() == guard_bytes, '餌 C:連結指到的那個檔被動到了'
assert open(p, 'rb').read() == blob, '餌 C:被擋下來的時候名冊不可以被改'
os.remove(link_dst)
# ── 餌 C2:旁邊躺著一份指向垃圾內容的 .facebak 連結 ──────────────
# 這一路不寫備份,只讀它 —— 讀出來不是名冊就要停手,而且名冊不可以被改。
os.symlink(outside, bak)
raw, lines = _reload()
try:
_quiet(cmd_set, p, raw, lines, faces, 'id00000000', 700, True)
except DataError:
pass
else:
raise AssertionError('餌 C2:壞掉的既有備份竟然放行 —— 改得進去、還原不回來')
assert open(p, 'rb').read() == blob, '餌 C2:被擋下來的時候名冊不可以被改'
os.remove(bak)
# ── 餌 G:備份是一個指向不存在目標的符號連結 ──────────────────
# os.path.exists() 對這種會回 False,錯誤訊息就會變成「找不到備份」,
# 把「那是一個壞掉的連結」這件事藏起來。
os.symlink(os.path.join(d, 'nope-does-not-exist'), bak)
assert not os.path.exists(bak) and os.path.lexists(bak), '餌 G 的前提不成立'
try:
_restore_from_backup(bak, p)
except SystemExit as e:
assert '找不到備份' not in str(e), \
'餌 G:壞掉的連結被報成「找不到備份」—— 使用者會去找不存在的問題'
else:
raise AssertionError('餌 G:壞掉的連結竟然還原得下去 —— 防線失效')
# 餌 G2:同一件事在使用者真正會走的那條路(main() 的 --restore)上也要成立。
real_argv = sys.argv
sys.argv = ['mvp_swap_face.py', d, '--restore']
buf = _io.StringIO()
try:
with contextlib.redirect_stdout(buf):
rc = main()
finally:
sys.argv = real_argv
assert rc == 2, '餌 G2:壞掉的連結竟然不是以 exit code 2 收場(是 %r)' % rc
assert '找不到' not in buf.getvalue(), \
'餌 G2:命令列那條路把壞掉的連結報成「找不到」—— 使用者會去找不存在的問題'
os.remove(bak)
# ── 餌 D:名冊本身是符號連結 ────────────────────────────────
# (main() 會先把連結解成真實路徑,所以正常路徑碰不到;
# 這裡直接叫 cmd_set,測的是最後那道守門本身。)
link_dir = os.path.join(d, 'linkdir') # 跟餌 C 的 link-target.dat 不同,別搞混
os.makedirs(link_dir)
lp = os.path.join(link_dir, 'attrib.dat')
os.symlink(p, lp)
before = open(p, 'rb').read()
raw, lines = _reload()
try:
_quiet(cmd_set, lp, raw, lines, faces, 'id00000000', 700, True)
except DataError:
pass
else:
raise AssertionError('餌 D:名冊是符號連結竟然照寫 —— 防線失效')
assert open(p, 'rb').read() == before, '餌 D:連結指到的那份名冊被動到了'
assert not os.path.lexists(lp + '.facebak'), '餌 D:不該產生備份'
os.remove(lp)
# ── 餌 E:還原做到一半失敗,正本必須原封不動 ────────────────────
raw, lines = _reload()
_quiet(cmd_set, p, raw, lines, faces, 'id00000000', 700, True)
live_before = open(p, 'rb').read()
assert live_before != blob, '餌 E 的前提不成立(名冊應該已經被改過)'
real_replace = os.replace
def _boom(*a, **kw):
raise OSError(28, '磁碟滿了(餌)')
os.replace = _boom
try:
_quiet(cmd_restore, p)
except OSError:
pass
else:
raise AssertionError('餌 E:換名失敗竟然還報成功 —— 防線失效')
finally:
os.replace = real_replace
assert open(p, 'rb').read() == live_before, '餌 E:換名失敗時正本不可以被動到'
leftovers = [f for f in os.listdir(db) if f.startswith('.attrib.dat.')]
assert not leftovers, '餌 E:失敗之後留下了暫存檔 %r' % leftovers
# ── 餌 F:複驗不過要自己還原回去,而且一定要丟出例外 ──────────────
# 把 build_new_line 換成一個「寫進去的值跟你要的不一樣」的版本
# (700 → 701,長度一樣、行數一樣)。這咬的是複驗「那一格要真的是新值」
# 那一道 —— 用多塞一行的方式咬不到它,那種會先被行數那一道接走(見餌 F2)。
_quiet(cmd_restore, p)
assert open(p, 'rb').read() == blob, '餌 F 的前提不成立'
real_build = globals()['build_new_line'] # 餌 F2 會再用一次
def _sabotage(line_bytes, new_face):
return real_build(line_bytes, new_face + 1)
raw, lines = _reload()
globals()['build_new_line'] = _sabotage
try:
_quiet(cmd_set, p, raw, lines, faces, 'id00000000', 700, True)
except DataError:
pass
else:
raise AssertionError('餌 F:複驗不過竟然沒有丟出例外 —— 會被當成成功')
finally:
globals()['build_new_line'] = real_build
assert open(p, 'rb').read() == blob, '餌 F:複驗不過之後應該已經自動還原回去'
# ── 餌 F2:複驗的「行數」那一道 ────────────────────────────
# 餌 F 咬到的是「改動行數 = 1」那一道。行數那一道要另外咬:
# 對**最後一列**動手、只在列尾多加一個換行 —— 每一列的內容都還一樣,
# 只有總行數多了一行。少了這個餌,行數那一道拿掉也不會有人發現。
real_build = globals()['build_new_line']
def _extra_newline(line_bytes, new_face):
return real_build(line_bytes, new_face) + b'\r\n'
raw, lines = _reload()
globals()['build_new_line'] = _extra_newline
try:
_quiet(cmd_set, p, raw, lines, faces, 'id00000002', 700, True)
except DataError:
pass
else:
raise AssertionError('餌 F2:寫完多出一行竟然算複驗通過 —— 防線失效')
finally:
globals()['build_new_line'] = real_build
assert open(p, 'rb').read() == blob, '餌 F2:複驗不過之後應該已經自動還原回去'
# ── 餌 H:「換名之前讀回來比一次」那一道 ────────────────────────
# 把 _sha256 換成永遠回同一個假答案,等於模擬「寫下去的跟手上的不一樣」。
# 備份與寫入兩條路都走它,所以兩條都要停手,而且名冊不可以被動到。
real_sha = globals()['_sha256']
globals()['_sha256'] = lambda _p: 'deadbeef'
try:
try:
_atomic_copy(p, os.path.join(db, 'should-not-appear.dat'))
except DataError:
pass
else:
raise AssertionError('餌 H:內容對不上竟然還換名(備份那條路)—— 防線失效')
assert not os.path.lexists(os.path.join(db, 'should-not-appear.dat')), \
'餌 H:對不上就不該產生那個檔'
raw, lines = _reload()
try:
_quiet(cmd_set, p, raw, lines, faces, 'id00000000', 700, True)
except DataError:
pass
else:
raise AssertionError('餌 H:內容對不上竟然還換名(寫入那條路)—— 防線失效')
finally:
globals()['_sha256'] = real_sha
assert open(p, 'rb').read() == blob, '餌 H:被擋下來的時候名冊不可以被改'
leftovers = [f for f in os.listdir(db) if f.startswith('.attrib.dat.')]
assert not leftovers, '餌 H:失敗之後留下了暫存檔 %r' % leftovers
# ── 餌 I:_NoInterrupt 真的把 Ctrl-C 壓到區塊結束才丟 ──────────────
# 這是「換名 + 登記」不可中斷的地基。它壞掉的話,下面餌 J 那個
# 「換完才中斷」的情境會退回舊行為:收尾照舊狀態說「沒動到」。
seq = []
old_h = signal.getsignal(signal.SIGINT)
try:
with _NoInterrupt():
assert signal.getsignal(signal.SIGINT) is not old_h, \
'餌 I:_NoInterrupt 根本沒把處理器裝上去'
_raise_sigint() # 這一下要被壓住
seq.append('inside') # 壓住了才跑得到這一行
except KeyboardInterrupt:
seq.append('after')
assert seq == ['inside', 'after'], \
'餌 I:Ctrl-C 沒有被壓到區塊結束才丟(%r)' % seq
assert signal.getsignal(signal.SIGINT) is old_h, \
'餌 I:離開之後沒有把原本的處理器還回去'
# ── 餌 J:換名剛做完就中斷 —— 登記一定要已經在了 ────────────────
# 這咬的正是這一輪要關掉的那個窗口:os.replace 成功、_REPLACED 還沒記,
# Ctrl-C 落在中間。舊寫法會讓收尾印「沒有改到任何檔案」。
j_src, j_dst = os.path.join(db, 'J-src.dat'), os.path.join(db, 'J-dst.dat')
open(j_src, 'wb').write(b'J')
marker = len(_REPLACED)
real_replace2 = os.replace
def _replace_then_sigint(a, b):
real_replace2(a, b)
_raise_sigint() # 換名剛做完,這時候中斷
os.replace = _replace_then_sigint
try:
_replace_and_record(j_src, j_dst, 'set')
except KeyboardInterrupt:
pass
else:
raise AssertionError('餌 J:被壓住的 Ctrl-C 應該在區塊結束時丟出來')
finally:
os.replace = real_replace2
assert os.path.exists(j_dst), '餌 J 的前提不成立(換名應該已經做完了)'
assert ('set', j_dst) in _REPLACED[marker:], \
'餌 J:換名做完了卻沒登記 —— Ctrl-C 的收尾會說「沒動到」'
# 三態的第三態:(a) 那道失效的時候(裝不上處理器)由 _INFLIGHT 接手,
# 收尾一樣不可以說「沒動到」。
saved_replaced, saved_pending = list(_REPLACED), list(_INFLIGHT)
_REPLACED[:], _INFLIGHT[:] = [], [('set', p)]
buf3 = _io.StringIO()
with contextlib.redirect_stdout(buf3):
_interrupt_note()
_REPLACED[:], _INFLIGHT[:] = saved_replaced[:marker], saved_pending
assert '沒有改到任何檔案' not in buf3.getvalue(), \
'餌 J:「正在替換」的狀態被收尾報成「沒動到」'
assert '正在替換' in buf3.getvalue(), \
'餌 J:「正在替換」的狀態沒有被說出來(%r)' % buf3.getvalue()
# ── 餌 K:名冊是符號連結 → 命令列那條路一律停手,不跟著連結寫 ──────
# (餌 D 咬的是 cmd_set 裡那道守門;這裡咬的是使用者真正會走的 main()。
# 2026-09-06 之前 main() 是「解成真實路徑再照常寫」,守門根本走不到。)
link_game = os.path.join(d, 'linkgame')
os.makedirs(os.path.join(link_game, 'data', 'database'))
lp2 = os.path.join(link_game, 'data', 'database', 'attrib.dat')
os.symlink(p, lp2)
before2 = open(p, 'rb').read()
sys.argv = ['mvp_swap_face.py', link_game, '--set', 'id00000000', '700', '--apply']
buf4 = _io.StringIO()
slipped = None
try:
with contextlib.redirect_stdout(buf4):
rc2 = main()
except (DataError, SystemExit, OSError) as e:
# 走到這裡代表這道守門沒攔住,是後面別的守門把它接下來的。
# 那不算這一道有效 —— 換一台 models.big 讀得到的機器就會真的寫下去。
rc2, slipped = None, e
finally:
sys.argv = real_argv
assert slipped is None, \
'餌 K:名冊是連結竟然一路走到寫入邏輯,才被別的守門攔下(%s)' % slipped
assert rc2 == 2, '餌 K:名冊是連結竟然不是以 exit code 2 收場(是 %r)' % rc2
assert '符號連結' in buf4.getvalue(), '餌 K:沒有說清楚為什麼停手(%r)' % buf4.getvalue()
assert os.path.islink(lp2), '餌 K:那個連結不該被換掉'
assert open(p, 'rb').read() == before2, '餌 K:連結指到的那份名冊被動到了'
assert not os.path.lexists(lp2 + '.facebak'), '餌 K:不該產生備份'
print('自我測試:全部通過(53 道檢查:9 道正向,44 道分在 13 組反向餌裡)')
print(' 測試資料在 %s(跑完不刪,想自己看的話可以進去翻)' % d)
return 0
# DataError 是「檔案跟預期不符」,一律印一行人看得懂的話然後 exit 1,
# 不丟 traceback,因為讀者多半不是工程師,traceback 幫不上忙。
# OSError 是「作業系統不讓你讀寫」,同樣不該噴 traceback:最常見的那一種
# (遊戲裝在 C:\Program Files (x86)\ 底下、命令提示字元不是用系統管理員身分開的)
# 讀者自己修得好,前提是有人告訴他發生什麼事。
# Ctrl-C 另外報。⚠️ 這裡原本無條件印「沒有改到任何檔案」——
# 寫入確實是先寫暫存檔再換名,但**中斷點可能落在換名之後**(複驗、印字都在後面),
# 那句話那時候就是假的:使用者以為什麼都沒發生,名冊其實已經被換掉了。
# 現在看 _REPLACED 裡有沒有記到換名,分四種情況說實話。兩種都是 exit 130。
def _interrupt_note():
kinds = [k for k, _p in _REPLACED]
# 三態的第三態:換名已經開始、而「換成了沒有」還不確定。這一筆只有在
# _NoInterrupt 沒裝上處理器(例如不在主執行緒)的時候才留得下來,
# 但留下來的時候絕對不可以說「沒動到」。
pendings = [(k, p) for k, p in _INFLIGHT if (k, p) not in _REPLACED]
if pendings and 'set' not in kinds and 'restore' not in kinds:
k, pth = pendings[-1]
print(' ⚠ 中斷的時候正在替換 %s —— 換成了沒有無法確定。' % os.path.basename(pth))
print(' 請用 --restore 還原,或自己拿旁邊那份 .facebak 比對一次。')
return
if 'set' in kinds:
print(' ⚠ 名冊**已經被改過了** —— 換名在你按下 Ctrl-C 之前就完成了。')
print(' 要退回動手之前的樣子,執行:')
print(' python3 %s "<遊戲資料夾>" --restore'
% os.path.basename(sys.argv[0] or 'mvp_swap_face.py'))
elif 'restore' in kinds:
print(' 註:還原在你按下 Ctrl-C 之前就完成了,名冊已經是備份那一份。')
elif 'backup' in kinds:
print(' 只產生了備份(.facebak),名冊本身沒有被改到。')
else:
print(' 沒有改到任何檔案。')
if __name__ == '__main__':
try:
sys.exit(main())
except DataError as e:
print('\n❌ %s' % e)
sys.exit(1)
except OSError as e:
print('\n❌ 讀寫檔案時被作業系統擋下來了:%s' % e)
print(' 最常見的兩種:')
print(' · 遊戲裝在 C:\\Program Files (x86)\\ 底下,而命令提示字元')
print(' 不是用系統管理員身分開的 —— 對著它按右鍵選「以系統管理員身分執行」。')
print(' · 那個檔正被遊戲或編輯器開著 —— 先把遊戲關掉。')
# 同一個理由:這句「名冊沒有被改到」也不能無條件印。
_interrupt_note()
sys.exit(1)
except KeyboardInterrupt:
print('\n已中斷。')
_interrupt_note()
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.
# ─────────────────────────────────────────────────────────