教學 › 2026 年的電腦怎麼跑得起 2005 年的遊戲
2026 年的電腦怎麼跑得起 2005 年的遊戲
這款遊戲是 2005 年為 Windows XP 做的 32 位元程式。 二十一年後的電腦跟它當年預設的世界差很多。 這一課講會遇到什麼、怎麼檢查你手上那份,以及一條流傳很久的老規矩真正的原因。
難度
★★☆ 會改執行檔
時間
約 15 分鐘
可以還原嗎
可以,一行指令
這一課不教什麼
界線在這裡:相容性修正可以教,規避授權驗證不能教。
這一課會教你改你自己那份執行檔的兩個地方:記憶體上限與螢幕解析度。 那兩件事都是「讓二十一年前的程式在今天的機器上跑得順」, 跟光碟檢查、授權驗證完全無關。
本站不教、不連結、也不討論任何規避光碟檢查或授權驗證的做法, 也不會告訴你去哪裡拿誰改好的執行檔 —— 本站的腳本改的是你自己那一份。 這是法律與版權說明第 3 節的紅線。
🛟 動手之前:先完整備份整個遊戲資料夾
把整個遊戲資料夾複製一份到別的地方,
包括裡面存放紀錄的資料夾。紀錄也可能放在遊戲資料夾外面:
「文件」裡的 Documents\MVP Baseball 2005,那一個要另外複製。
怎麼整包複製、紀錄(王朝、名單、設定)的兩個位置各在哪、找不到怎麼找: 看備份與還原 SOP 的「整包複製」。
⚡ 只想趕快做完?照這五步(要先裝好 Python,沒裝的看第 0 課操作五)
下面照 Mac 寫法;Windows 把 python3 換成 python。
- 下載 mvp_modernize.py(零相依,不用裝任何套件)。下載後先把它搬進專用資料夾
MVP_Tools(第 0 課操作六)。再打開檔案總管,進到MVP_Tools,點一下最上面的網址列,把字刪掉改打cmd按 Enter,後面的指令都在開出來的這個視窗打(Mac 開終端機先打cd ~/MVP_Tools)。 - 先唯讀看你手上那顆是什麼,完全不會動到檔案
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" - 預覽要改哪幾個位元組,這一步還是不會寫入
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 - 確認列出來的位元組沒問題,才真的改(會先備份,寫完自動複驗)
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 --apply - 不對勁就一行還原
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --restore
改過王朝年份、換了執行檔之後王朝又變回 2005?那是另一顆旋鈕 --dynasty-year,
看第四個旋鈕:王朝模式的起始年份(建議跟上面的 --4gb 寫在同一行,一次做完)。
想讓王朝年份跟著電腦日期自動走(不用每年重做、老闆模式又不受影響),是同一顆旋鈕的 clock 寫法,看第三輪。
中文版配大球場會跳出的話,那是另一顆旋鈕 --pool,看
中文版配大球場就跳出那一課。
想知道為什麼是 4 GB 而不是 8 GB、以及「球場不能超過 20MB」到底怎麼回事,往下讀;
只想複習,跳到📌 重點整理。
先看清楚你手上那份是什麼
所有問題的起點都是同一件事:這是一個 32 位元程式。 用診斷腳本就看得到(全程唯讀):
python -I mvp_check_setup.py "<你的遊戲資料夾>"
它會讀執行檔的 PE 檔頭,告訴你兩件事:架構是 32 還是 64 位元、 以及有沒有開「大位址」旗標。本站量到剛安裝好的原版是:
| 項目 | 剛安裝好的原版 | 意思 |
|---|---|---|
| 架構 | 0x014C = 32 位元 x86 | 2005 年的主流 |
大位址旗標LARGE_ADDRESS_AWARE | 沒有開 | 所以它最多只能用 2 GB 記憶體,不管你的電腦有多少 |
「球場不能超過 20MB」那條老規矩,本站量到的是這樣
玩這款遊戲的人多半聽過一條規矩:球場模組太大就會當。 會當是真的(下面有班主任自己機器上的例子), 但「20MB」那個數字本站量過,它站不住, 所以這一節不會把它講成 2 GB 那道牆的症狀。
先講那道牆本身:一個 32 位元程式在 32 位元 Windows 上最多只能用 2 GB 的位址空間, 不管你的電腦裝了多少記憶體。遊戲要載入的東西超過那個上限就會出事。 但「哪一種當機是撞到這道牆」本站沒有量過,所以底下把它當候選解釋, 不當這款遊戲所有記憶體問題的共同根源。
而且本站已經量到至少一種當機不是它:中文版配大球場跳出,
卡的是遊戲自己切的那塊 64 MB 記憶體池,不是位址空間,
單獨掛大位址旗標照樣跳出(見中文版配大球場就跳出那一課)。
底下兩件事也指向同一個方向:那條規矩的兩個數字量的不是同一個檔,
以及一個 models.big 大了 389 MB 卻一直沒事的反例。
這一段哪些是量到的、哪些不是
量到的:執行檔是 32 位元、大位址旗標沒開(上面那張表,你自己也驗得到)。
沒有量的:遊戲執行時各個部分各佔多少記憶體。 本站沒有做過執行時的記憶體量測,所以不列任何分配數字。
⚠️ 本站原本在這裡寫過一句話,2026-08-28 自己刪掉了
原文是:「中文化的版本又多了字型與介面資源,緩衝更少」。 那句話很合理、很多人也這樣講,但實際去量之後不成立:
| 剛安裝好的英文版 | 剛安裝好的中文版 | |
|---|---|---|
data\ 總量 | 1.3 GB | 1.3 GB |
fonts.big | 204 KB | 1.6 MB |
| 兩邊同名檔的大小差異合計 | +1.2 MB(62 個檔) | |
中文版多吃的是 1.4 MB 等級,不是 100 MB 等級。 (這兩個數字量的不是同一件事:只算變大的那些檔是 1.43 MiB, 扣掉變小的檔之後的淨值才是上表那個 +1.2 MB。) 所以「中文版比較容易撞這一節講的 2 GB 位址空間上限」這個說法,本站沒有證據,已經拿掉。
⭐ 2026-09-05 訂正:這一格原本寫「那說法從哪來,本站不知道」, 還掛著一個「未解」—— 那是過期的。2026-08-28 當天稍晚本站就把它解開了: 卡住的不是位址空間,是遊戲自己寫死的那塊 64 MB 記憶體池。 中文字型展開成貼圖要佔掉那塊池子的 21.1%(13.50 MB),英文只佔 0.6%; 把池子改成 128 MB,現象就不再出現(對照實驗與做法見 中文版配大球場就跳出那一課)。 未解 剩下沒解開的是:那 13.50 MB 是算出來的, 本站到現在仍然沒有量過遊戲執行時的真實記憶體用量。
⭐ 2026-08-28:那條規矩的數字有兩種版本,而它們量的不是同一個東西
本站自己就同時寫著兩個數字:這一課寫 20MB,
清孤兒那一課寫 10MB。
兩個都來自班主任 —— 一個是他十四年筆記裡的說法,
一個是他資料夾上直接寫著的標籤(stadium - 10MB)。
同一條規矩傳出兩個數字,本身就說明它是口耳相傳,不是量出來的。
但真正的原因更簡單:兩個數字貼在不同的檔案上。
| 被指的東西 | 是什麼 | 剛安裝好的原版(MiB) | 社群最肥的一份(MiB) |
|---|---|---|---|
data\frontend\stadiums.big | 一個容器檔 | 28.77 MB | 38.21 MB |
data\stadium\*.big | 87 個各別球場檔 | 最大 3.99 MB | 最大 18.44 MB |
單位:這張表用 MiB(1 MiB = 1,048,576 bytes,就是檔案總管顯示的那種 MB)。
原始位元組數:剛安裝好的原版 stadiums.big 是 30,162,721 bytes,
社群最肥的那一份是 40,067,694 bytes。
⚠️ 下一節「一個玩家的實戰發現」那張表用的是十進位 MB(1 MB = 1,000,000 bytes),
所以同一個檔在那裡寫 30.2 與 40.1 —— 是同一個檔的同一個狀態,不是量到兩組數字。
看第一列。「不能超過 20MB」如果指的是那個容器 —— 剛安裝好、從來沒被動過的官方版本就已經 28.77 MiB(30,162,721 bytes),比那個上限多 44%。 兩份原版(英文版與繁體中文版)都是同一個數字。 EA 自己出貨就「超標」,所以 20MB 不會是一條硬上限。
「10MB」那個是貼在各別球場檔上的。它的量級比較合理 (原版最大 3.99 MB,社群改過的最大 18.44 MB), 但一樣沒有人量過邊界 —— 沒有人做過「放多大會當、放多小不會當」的實驗。
未解 本站能說的是:這條規矩的兩個數字,一個已經被原版自己推翻,另一個沒有人驗過。 真正該做的事跟數字無關 —— 把沒人指向的位元組清掉, 那件事本身有明確的量測支撐(那個 18.44 MB 的檔,71.9% 是垃圾)。
一個玩家的實戰發現:把球衣與球場換回原版就不當了
來源是班主任自己的機器,不是量出來的推論:
裝了社群模組之後會跳出,把 uniforms.big 與 stadiums.big
換回剛安裝好的中文版那兩個檔,就不跳了。
本站把那幾個檔實際量了一遍:
| 檔案 | 剛安裝好的中文版(MB) | 裝了社群模組(MB) | 差 |
|---|---|---|---|
uniforms.big | 19.4 MB | 37.7 MB | +18.3 MB |
stadiums.big | 30.2 MB | 40.1 MB | +9.9 MB |
logos.big | 1.8 MB | 2.5 MB | +0.6 MB |
models.big | 173 MB | 562 MB | +389 MB |
data\stadium\(87 個球場檔 + lighting.csv) | 283 MB | 283 MB | 完全一樣 |
換回那兩個檔=少載 28 MB。而那正好是會不會當的分界。
單位:這張表用十進位 MB(1 MB = 1,000,000 bytes)。
所以 stadiums.big 這一列的 30.2 / 40.1,
跟上一節那張 MiB 表的 28.77 / 38.21 是同一個檔的同一組位元組
(30,162,721 與 40,067,694 bytes),不是兩次量到不同的值。
例外是 data\stadium\ 那一列的 283,那個是 MiB(296,846,196 bytes,十進位是 297 MB)——
那一列兩邊完全一樣,換算不影響結論。
⚠️ 2026-09-05 訂正:這張表是 2026-08-28 在班主任機器上量的。
其他四列今天重新量都還是同一組數字,只有 data\stadium\ 那一列不是了 ——
那台機器後來又放進新的球場檔,同一個資料夾今天是 647.6 MiB。
這不影響本節的結論(當年換回球衣與球場就不當了,而球場資料夾兩邊確實一樣),
但你照著去量會對不上,所以寫在這裡。
但這裡有一個反例,它排除了最直覺的解釋
最直覺的解釋是「模組讓總量太大,撐爆記憶體」。那個解釋不成立:
models.big 大了 389 MB(3.2 倍),而班主任並沒有把它換回去 ——
它一直是那個 562 MB 的版本,遊戲照跑。如果問題是總量,那個檔早就該先爆了。
推論(本站沒有量過執行時的記憶體,所以只能推):
球衣與球場是開一場比賽時要一起載進來的東西,
而 models.big 是用到哪個球員才載哪個。
那 28 MB 是「同時擠在記憶體裡」的 28 MB,不是硬碟上的 28 MB。
這個推論解釋得了「為什麼大 389 MB 的檔沒事」,而總量說解釋不了 —— 這是本站採信它的唯一理由,不是因為它聽起來合理。 後來找到的 64 MB 記憶體池(見中文版配大球場就跳出) 跟這個推論的方向一致,但執行時的用量本站仍然沒量到。 未解 要確認的話得量執行時的記憶體。
⚠️ 單位:本頁這幾個 MB 是用 1,000,000 位元組算的(173 MB / 562 MB / 差 389 MB)。 封裝檔為什麼會越長越大那一課用的是 1,048,576 位元組, 同一個差在那裡寫成 371 MB —— 兩個數字是同一件事,只是尺不一樣。
所以你可以怎麼做
如果你裝了模組之後會跳出,先把 uniforms.big 與
stadiums.big 換回原版那兩個試試看 ——
那是班主任實際走通的路,而且比重灌整包快得多。
代價是球衣會變回原版。先確認是不是這個原因,再決定要不要留模組。
⚠️ 換之前先備份。本站的備份那一課有現成的做法。
為什麼是 4 GB?可以改成 8 GB 嗎?
這是最常被問的問題,而且答案很乾淨:不能,而且原因不是 EA 沒做,是數學上不可能。
先搞清楚那個旗標是什麼
它不是一個「設定多少」的旋鈕,是一個開關 ——
PE 檔頭裡 Characteristics 那兩個位元組中的一個位元(0x0020)。
它只有開跟不開,沒有「開到 8 GB」這個選項。
為什麼天花板是 4 GB
32 位元程式用的是 32 位元的指標。指標就是「記憶體位址」, 而一個 32 位元的數字最多能數到:
2³² = 4,294,967,296 = 4 GB
程式不是被誰限制在 4 GB,是它連「第 4 GB 之後的位址怎麼寫」都寫不出來。 就像三位數的門牌最多只能寫到 999 號 —— 不是規定不准蓋第 1000 棟,是門牌上沒有那個位置。
那預設的 2 GB 是怎麼來的
Windows 把那 4 GB 的位址空間對半分:
| 位址範圍 | 給誰用 |
|---|---|
0x00000000 – 0x7FFFFFFF | 你的程式(2 GB) |
0x80000000 – 0xFFFFFFFF | 作業系統核心(2 GB) |
2005 年的程式多半跑在 32 位元的 Windows 上,那時核心真的需要待在同一個 4 GB 裡, 所以對半分是合理的。
旗標打開之後發生什麼事
那個位元的正式名稱是 IMAGE_FILE_LARGE_ADDRESS_AWARE,
直譯是「這支程式知道怎麼處理大位址」。它是程式對系統的一句宣告,不是要求。
為什麼需要宣告?因為有些老程式會把指標的最高位元當成旗標在用 (反正 2 GB 以下用不到那個位元)。這種程式一旦拿到 2 GB 以上的位址就會算錯。 所以 Windows 預設保守:你沒說你處理得了,我就不給你。
而在 64 位元的 Windows 上,核心自己有 64 位元的空間可以待, 不必再從程式的 4 GB 裡切走一半。所以旗標一開,程式就拿到完整的 4 GB。
所以 8 GB 只有一條路,而那條路走不通
要超過 4 GB,程式本身必須是 64 位元的 —— 指標變成 64 位元, 天花板才會抬高。而那需要把原始碼重新編譯一次。
原始碼在 EA 手上,不是改幾個位元組能做到的事。 把 32 位元執行檔「改成」64 位元是做不到的 —— 那不是換個設定,是整個程式要重蓋。
那 4 GB 夠不夠?本站沒有答案
老實說:本站沒有量過這款遊戲執行時到底吃多少記憶體, 所以不知道 4 GB 對你會不會夠。
你可以自己量,而且很簡單:Windows 上開工作管理員 →「詳細資料」分頁 → 在欄位標題上按右鍵 → 勾「認可大小」,然後玩到會當的那一刻看那個數字。 套 4GB 之前跟之後各量一次。
如果你量了,歡迎回報。這是本站目前最想要、而且只有玩家的機器做得到的一個數字。
解析度表就在執行檔裡,而且是一張很單純的表
遊戲內建的解析度只到 1280x1024(2005 年的常見上限)。那不是設定檔決定的, 是寫在執行檔裡的一張表。本站在兩份剛安裝好的原版裡都找到了它:
| 第幾筆 | 寬 | 高 | 色深 |
|---|---|---|---|
| 1 | 640 | 480 | 16 位元 |
| 2 | 640 | 480 | 32 位元 |
| 3 · 4 | 800 | 600 | 16 / 32 位元 |
| 5 · 6 | 1024 | 768 | 16 / 32 位元 |
| 7 · 8 | 1280 | 1024 | 16 / 32 位元 |
每一筆 16 個位元組:寬(4) + 高(4) + 色深(4) + 保留(4),全部是 32 位元小端序。
所謂「改解析度」就是把其中一筆換成你要的尺寸 ——
本站的腳本換的是最前面那組 640x480,因為那是今天不會有人選的,換掉它損失最小。
⚠️ 位址不能記,要去找
那張表在英文版原版的 0x4C3FD8、在中文版原版的 0x52F860。
連「大位址旗標」的位置也不一樣(0x00013E vs 0x000146)。
所以本站的腳本不記位址,它是拿表的後四筆
(1024x768 與 1280x1024,各兩種色深)當特徵去搜。
為什麼是後四筆而不是開頭那兩筆?因為開頭那兩筆正是要被換掉的 ——
第一版真的拿它當特徵,結果改完之後腳本再也找不到自己改過的表。
⚠️ 改了解析度表,畫面還是被拉伸的
這是本站這一課的一個限制,而且是別人指出來的
把解析度表改成 1920×1080,遊戲確實會用 1920×1080 輸出 —— 像素是對的。 但畫面的比例還是 4:3 拉開來的。 球場會變胖,球員會變寬。
原因是那張表只告訴遊戲「畫面有多少像素」, 沒有告訴它「這些像素的形狀變了」。 遊戲內部算 3D 投影用的視野比例仍然是 2005 年那個 4:3。
本站原本沒有講這件事。 是 2026 年 7 月出現的一個工具點破的 —— 它的說明第一句就是「現有的寬螢幕改法給你像素完美的輸出,但比例還是拉伸的」。
真正修比例的做法長什麼樣
那個工具叫 mvp2005fix(MIT 授權,2026-07 出現,原始碼公開)。
本站沒有使用它,但讀了它的原始碼,做法值得寫下來:
- 它不改磁碟上的執行檔。用一個啟動器把遊戲叫起來, 再注入一個 DLL,在記憶體裡改。不想用就不要跑啟動器, 沒有還原問題,也不會跟本站的腳本互相覆蓋
- 比例是在送進顯示卡的路上修的。
它攔截 Direct3D 8 的
SetVertexShaderConstant, 當送的是投影矩陣時,把第一列(X 軸)那四個浮點數乘上一個係數 (16:9 是 0.75)。遊戲自己的數學一行都沒改,改的是遞給 GPU 的那份 - 它還修了另一個老問題:硬碟太大時存不了設定檔。
做法是攔截
GetDiskFreeSpaceExA,回報一個假的 1 GB。 班主任的十四年筆記裡就記過這個雷(3 TB 硬碟)
✅ 它跟本站的腳本不會打架 —— 本站實測過
它的說明有一句警告:「如果你已經用別的解析度工具改過執行檔, 請從乾淨的執行檔開始。本工具找的是遊戲原本的 800×600 設定。」
本站去量了兩邊改的是哪一筆:
| 第 1、2 筆(640×480) | 第 3、4 筆(800×600) | |
|---|---|---|
| 本站的腳本 | 改這兩筆 | 不動 |
mvp2005fix | 不動 | 找這兩筆 |
兩個改的是不同的筆,可以並存。 實測班主任那顆已經被社群工具改成 1920×1080 的執行檔, 第 3、4 筆還是 800×600,所以那個工具仍然找得到。 它警告的是「動到 800×600 的工具」,本站的沒動。
⚠️ 本站沒有實際跑過 mvp2005fix,
上面是讀原始碼與量本站自己的執行檔得到的。它實際跑起來效果如何,本站沒有驗。
所以這三件事「解決了」沒有?——班主任 2026-08-29 問的
| 問題 | 有沒有解 | 是誰的解 | 本站驗過嗎 |
|---|---|---|---|
| 16:9 畫面被拉伸 | 有 | 第三方工具,執行時攔投影矩陣 | ❌ 沒有 |
| 大容量硬碟存不了檔 | 有 | 第三方工具,攔磁碟查詢回報假的 1 GB | ❌ 沒有 |
| Wine 底下手把按鈕錯位 | 有 | 第三方工具,另存一份設定檔 | ❌ 沒有 |
三個都不是本站的解。而且前兩個本站的做法原則上做不到 —— 本站只改磁碟上的檔案,而那兩個修法都必須在遊戲執行時攔截它對系統的呼叫。 這不是還沒做,是兩種不同的東西。
第三個(手把)不一樣:本站 已經有一課在教改設定檔, 那支工具比本站高明的地方只是另存新檔而不覆蓋原檔——那是技巧,不是障礙。
順帶查到的:大硬碟那條可能比說明書講的更具體
本站去看執行檔用哪個 API:兩個都用——
GetDiskFreeSpaceA(舊的)兩處、
GetDiskFreeSpaceExA(新的,回傳 64 位元)三處。
而那支工具攔的是新的那個。
如果它攔新的還有效,代表問題不是「用了舊 API」,而是 遊戲拿到 64 位元的數字之後塞進 32 位元的變數—— 3 TB ≈ 3×1012,遠超過 32 位元整數能表示的 2.1×109, 溢位之後變負數,遊戲就以為沒空間。
推論 這一段是從「它攔哪個 API」反推的,本站沒有反組譯那段程式碼證實。
同一支腳本還有第三個旋鈕:記憶體池
2026-08-28 加的。除了 --4gb 與 --resolution,
這支腳本還有 --pool,改的是遊戲自己切的那一塊記憶體。
| 4GB(大位址) | 記憶體池 | |
|---|---|---|
| 它在哪 | PE 檔頭的一個位元 | 程式碼裡的一個 push |
| 它管什麼 | 程式最多能用到多少位址空間 | 遊戲實際跟系統要了多大一塊(64 MB) |
| 比喻 | 房間變大 | 櫃子變大 |
| 光碟原版改得動嗎 | ✅ 可以 | ❌ 帶有光碟防拷保護,找不到 |
這兩件事不能互相取代。 中文版配大球場會跳出就是被那塊 64 MB 卡住的,而套了 4GB 也沒用 —— 房間變大,櫃子還是那麼小。完整的來龍去脈、13.50 MB 是怎麼算出來的、 以及本站走錯的十二條路,在 中文版配大球場就跳出那一課。
唯讀檢查(第 2 步)現在會多印一行「記憶體池 64 MB」。
看到「找不到」代表你手上這顆是光碟原版,那一項做不到,但 4GB 與解析度照樣可以改 ——
只是要把 --pool 從指令裡拿掉再跑一次。
三個參數寫在同一行的話,腳本會在記憶體池那一關整個停下來(結束碼 2、一個位元組都不寫、
連備份都不產生),4GB 與解析度也不會被套用。本站拿剛安裝好的中文版
mvp2005.exe 實測:
--pool 128 --4gb --resolution 1920x1080 --apply 停在那一關、檔案 SHA-256 沒變;
把 --pool 拿掉重跑,就正常算出 7 個位元組的計畫。
第四個旋鈕:王朝模式的起始年份
2026-10-29 加的。起因是一位玩家的回報:王朝的年份明明改過了,換了一顆執行檔(最常見的是重新走一遍 4GB 流程), 王朝又顯示回 2005。他用過社群的小工具,也照把賽程換到任何年份那一課做過。
本站量到的(觀察)
他寄來兩顆執行檔:一顆「換了執行檔之後王朝變回 2005」,一顆「不會」。兩顆大小一樣,
逐位元組比對只差 14 個位元組。其中 12 個是 4GB 流程自己動的
(大位址旗標、檢查碼、記憶體池、解析度表),剩下兩個是程式碼裡寫死的 2005 被手改成 2026
(2005 = 0x07D5、2026 = 0x07EA,兩處都只有低位元組從 D5 變成 EA)。
也就是說,「不會變回去」的那一顆,是有人手改過這兩處的;而重新用 4GB 流程做出來的新執行檔,
沒有帶著那兩處修改。
| 位置 | 反組譯讀到的長相 | 它管什麼 |
|---|---|---|
| 甲處 | cmp al,1 → jne → push 2005 → mov ecx,esi → call(設定日曆年份的函式) |
某個模式判斷成立,就把日曆年份設成 2005。呼叫它的那個初始化函式,設完年份馬上把「目前日期」存起來。 實測:新建王朝時走到這裡,年份被設成 2005;載入既有存檔時不會(見下一節) |
| 乙處 | 在堆疊上組一個「3 月、26 日、2005 年」的日期,轉成天數 | 推論:呼叫端的判斷是不是王朝的模式用這個固定日期算球員年齡,王朝則用「目前日期」算。這一處本站沒有單獨驗證 |
「某個模式判斷」的名字,本站只讀了反組譯、沒有進遊戲證實。 甲處是不是還有別的觸發時機,也沒有量過 —— 本站原本寫「建立或載入時」,載入那一半被下面的實測推翻了,已更正。
實測結果(2026-10-29,一台 Windows)
同一份遊戲資料夾(裡面的賽程是 2026 年的),用三顆執行檔各開一次新王朝;再把其中一個存檔,分別換三顆執行檔去載入:
| 做了什麼 | 畫面上的日期 | 當日賽事(畫面下方那一列) |
|---|---|---|
| 沒改年份的執行檔,開新王朝 | 2005(4 月 3 日) | OAK at MIN、LA at SD、NYM at CLE … |
| 手改過兩處的執行檔,開新王朝 | 2026(3 月 29 日) | MIN at OAK、SD at LA、CLE at NYM … |
| 用這支腳本改過兩處的執行檔,開新王朝 | 2026(3 月 29 日) | 同上 |
| 用上面那顆建好的存檔,分別拿「腳本改的」「手改的」「沒改年份的」三顆去載入 | 三次都是 2026 | — |
- 觀察:沒改年份的執行檔開新王朝是 2005;改掉兩處的(手改的、腳本改的)都是 2026。
- 觀察:載入既有存檔不看執行檔 —— 連沒改年份的那顆,載入 2026 的存檔也是 2026。 所以「年份被洗回 2005」只發生在建立新王朝的那一刻;已經建好的王朝,換執行檔也不會變。
- 觀察:當日賽事的對戰組合不一樣:2005 那一顆列的是 2005 年的對戰,2026 那兩顆列的是 2026 年的。 推論:年份不只是畫面上的字,它還連帶決定遊戲用哪一份賽程。這個推論本站沒有單獨驗證。
只有一台機器、一份遊戲資料夾、一位測試者。沒測的:非王朝模式的球員年齡(乙處)、光碟保護的原版、中文版。
第二輪實測(2026-10-29,同一位測試者;遊戲資料夾裡的賽程檔是 2005 年的)
上一輪之後,本站寫「王朝最多打到 2034」(理由是一個 30 格的陣列)。這一輪實機證明那句講錯了範圍:
| 做了什麼 | 看到什麼 |
|---|---|
| 起始年份 2034 的執行檔,開新王朝,打完一季 | 開季「星期日 4 月 2 日 2034」;打完進到 2035(月曆「3 月 2035」),沒被擋、沒當機 |
| 起始年份 2100 的執行檔,開新王朝,打完一季 | 開季「星期日 4 月 4 日 2100」,4 月 8 日是星期四;整季打完進到 2101(「星期日 3 月 6 日 2101」)。星期全部對得上真實日曆,連 2100 不是閏年都對 |
| 起始年份 2100 的執行檔,開老闆模式 | 沒有「前進下一日」,選單直接變成「退休」 |
| 起始 2026 的執行檔開老闆模式,看「組織資料」 | 擁有時間 21 Years(第一天!)、淨值 $940,671;測試者回報沒改年份的執行檔是 $2,000,000 |
- 觀察:賽程檔是 2005 年的,執行檔設 2034 或 2100,畫面照樣是 2034 或 2100。連同上一輪(賽程檔 2026 年、執行檔沒改 → 畫面 2005), 兩組合起來:新王朝的年份與日期由執行檔決定,賽程檔的年份不用跟著換。只測了王朝模式。
- 推論(反組譯、遊戲自己的字串、上面的實測,三者一致):王朝模式的上限是寫死的 2125 年(遊戲介面寫「120 year franchise」), 老闆模式的上限是另一個寫死的 2035 年(遊戲說「30 Years」)。上一版說的「30 格陣列」只有老闆模式會寫。
- 老闆模式不會跟著變長:起始年份 Y 的老闆模式只能打 2035 − Y 季(依程式碼推算;起始 2026 是 9 季),而且第一天會顯示「擁有時間 Y − 2005 年」。 想完整玩老闆模式(30 年),請用沒改過年份的執行檔,或改用下一節的時鐘版(王朝跟著電腦日期走,老闆模式維持 2005)。
沒測的:起始年份 2101 到 2123、非王朝模式的球員年齡(乙處)、光碟保護的原版、中文版。老闆模式的上限(2035)本站只實測了「起始 2100 直接退休」,沒有真的打到 2034 去看。
第三輪:讓王朝年份「跟著電腦時鐘走」(--dynasty-year clock)
前兩輪的做法是把年份寫死成一個數字,會有兩個麻煩:每過一年就得重做一次;而且老闆模式被一起帶著走,起始年份越晚,老闆模式能打的季數越少 (起始 2026 只剩 9 季,見上面「第二輪實測」)。測試者因此提了另一個方向: 王朝的起始年份不要寫死,改成「開新王朝的那一刻,讀電腦的日期」;老闆模式則維持原版的 2005 起算。目標是一顆執行檔就夠。
做法(不用讀機器碼也看得懂的版本):甲處那條 5 個位元組的 push 2005,換成 call 洞穴。
洞穴是一小段(133 個位元組)機器碼,放在檔案最後一個區段的空白尾端 ——
那個區段本來就可執行、檔案裡就有,所以不加區段、不改檔案大小。洞穴只做三件事:
- 先問「現在是不是老闆模式」。是 → 照原版
push 2005,跟沒改過一模一樣。 - 不是(王朝) → 用執行檔本來就匯入的
LoadLibraryA與GetProcAddress去找 Windows 的GetLocalTime,問電腦現在是幾年,夾在 2005 到 2100 之間,當成年份。 - 找不到
GetLocalTime→ 退回 2005(原版的行為)。
乙處(不是王朝的模式拿來算球員年齡的 3 月 26 日基準日)不動,維持 2005。
實測結果(2026-10-29,同一位測試者、同一台 Windows;遊戲資料夾裡的賽程檔是 2005 年的):
| 做了什麼 | 看到什麼 |
|---|---|
| 電腦日期是當時的 2026,開新王朝 | 「星期日 3 月 29 日 2026」 |
| 把 Windows 的日期改成 2030,開新王朝 | 「星期日 3 月 31 日 2030」(真實日曆 2030-03-31 確實是星期日)。這一列證明年份真的跟著時鐘走,不是寫死 2026 |
| 把 Windows 的日期改成 2001,開新王朝 | 「星期日 4 月 3 日 2005」—— 被夾回下限 2005,沒有壞 |
| 開老闆模式 | 日期 2005(4 月 3 日),選單有「前進至下一日」,不是「退休」 |
| 老闆模式 →「組織資料」 | 擁有時間 <1 Year、淨值 $2,000,000 —— 跟原版一樣(對照:起始 2026 的寫死版是 21 Years / $940,671) |
| 載入一個舊的王朝存檔 | 載得進去,日期是存檔自己的(3 月 31 日 2030) |
- 觀察:開新王朝時,年份 = 當下的電腦年份(現在、2030 兩個點對得上),早於 2005 的夾回 2005。
- 觀察:老闆模式維持原版的 2005 起算,有「前進至下一日」,組織資料也跟原版一樣。 代價:老闆模式畫面上的年份永遠是 2005。測試者回報可以接受。
- 觀察:賽程檔是 2005 年的,開出來的王朝照樣是電腦的年份 —— 賽程檔不用另外換(只測了王朝)。
- 沒拍到、靠測試者口頭回報的:老闆模式前進兩天、做工具前後的 SHA-256 比對、測完把日期改回自動。 舊存檔那一列的照片也看不出載入當下電腦日期是 2030 還是已經改回現在,所以它證明「載得進去、日期是存檔的」, 不能單獨證明「載入完全不看時鐘」。
- 沒測的:2100 那一側的夾限(只是照程式碼推的)、Wine / macOS、中文版與其他版本、老闆模式打完整整 30 年。只有一台機器、一位測試者。
怎麼做(跟其他旋鈕一樣:先預覽、再加 --apply):
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 --dynasty-year clock
- 預覽會寫「把王朝起始年份從寫死的 2005 改成跟著電腦時鐘走(甲處 5 個位元組換成呼叫、洞穴寫入 133 個位元組)」。
本站拿一顆沒改過年份的執行檔實跑,這一項動 119 個位元組(洞穴裡本來就是 0 的位元組不算);
加
--keep-checksum的話整個檔就只動這 119 個。 - 已經用數字寫死過年份的檔(這支腳本改的,或別人手改過兩處的)也能直接轉成時鐘版,乙處會被改回 2005。
想換回寫死年份就給數字:
--dynasty-year 2026,洞穴清成 0、甲處換回原樣, 結果和直接從沒改過的檔改成 2026 逐位元組相同(自我測試每次都驗)。--restore照樣還原得回去。 - 洞穴放得下的條件是從檔案內容算出來的(不記位址),缺一個就整行停下來、不寫也不備份:
執行檔要同時匯入
LoadLibraryA與GetProcAddress、甲處前面兩支「模式判斷」的內部結構要對得上、最後一個區段要可執行而且尾端有夠用的空白。 本站手邊 14 種內容不同的執行檔(原版、4GB、各種解析度、起始年份 2005 / 2026 / 2034 / 2100)全都算得出同一個洞穴位置; 其中拿沒改過年份的那顆(實測用的底)實跑,做出來的結果和實測通過的那顆逐位元組相同。另外 12 種其他來歷的檔(含英文與中文原版、各版更新檔)找不到甲處,會停下來。 - 洞穴的位元組如果被別的工具改過,工具會說「不是本工具做的」並且不碰。
為什麼光改賽程不夠
- 改賽程日期(把賽程換到任何年份):改的是
schedule.big裡的日期字串, 沒有碰上面那兩處。 - 社群小工具 ScheduleUpdater:本站把它反組譯讀過。它是執行中去改遊戲記憶體裡所有場次的日期,
也沒有碰上面那兩處。另外它先用行程名稱
MVP2005去找遊戲,找不到就直接結束,不印任何訊息 —— 所以如果遊戲行程的名稱不是MVP2005(例如執行檔被改了名字),它會什麼都不做,看起來就像「沒效」。 Windows 上可以先用tasklist | findstr /i mvp2005確認遊戲行程有沒有被列出來。 (這位玩家寄來的兩顆檔名都是mvp2005.exe,所以這一條不是他這次的原因,只是順便提醒。)
兩者都沒碰那兩處,所以新建王朝時年份仍被甲處設成 2005(上面實測:同一份 2026 賽程,沒改年份的執行檔開出來就是 2005),前面做的全白做。把那兩處改掉,才是跟著執行檔走的做法: 換了執行檔也要重做這一步,所以建議跟 4GB 流程寫在同一行指令,一次做完。
怎麼做
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 --dynasty-year 2026
先這樣預覽(不寫檔),確認列出「把王朝起始年份從 2005 改成 2026(兩處)」,再同一行結尾加 --apply。
年份兩處是用內容去找的(不記位址),各自要剛好找到一個才動手;找不到或找到不只一個就整行停下來,
同一行的 --4gb 等也不會被套用。
想連 PE 檔頭那個檢查碼也完全不動(原本寫什麼就留什麼),同一行加 --keep-checksum。
預設不變:檢查碼原本有填,就跟著重算。Windows 對一般程式不看這個欄位,所以兩種做法遊戲都照開。
用 --keep-checksum 搭配 --dynasty-year 時,整個檔案只會改到那兩個年份。
本站拿手邊 8 種內容不同的執行檔(原版、4GB、各種解析度、記憶體池不同的)測過,
兩處都剛好命中一次,年份都是 2005。把玩家那顆「會變回 2005」的複本跑上面的 --dynasty-year 2026,
結果和他那顆「不會變回去」的,兩處年份位元組逐位元組相同,
剩下的差異只有 4GB 流程的設定。
⚠️ 這個旋鈕的限制(先看再用)
- 只在一台 Windows 實機驗過(2026-10-29,結果見上面「實測結果」與「第二輪實測」):新王朝會顯示你設定的年份,打完一季會進下一年。 起始年份 2101 到 2123、非王朝模式的球員年齡、中文版都還沒測。你做完請照 ✅ 成功標準最後一條確認,結果回報給本站。
- 範圍是 2005 到 2100(2100 是實測過的最大值)。王朝模式自己的上限是遊戲寫死的 2125 年(遊戲介面寫「120 year franchise」), 所以起始年份離 2125 越近,能打的季數越少;從 2026 起約 99 季。本站上一版寫「最多 2034,因為每季往 30 格陣列記一筆」是錯的: 那個陣列只有老闆模式會寫,見下一條與版本紀錄。
- ⚠️ 老闆模式(Owner Mode)不會跟著變長。它的上限是另一個寫死的 2035 年(遊戲說「30 Years」),
所以起始年份 Y 的老闆模式只能打 2035 − Y 季(依程式碼推算):2026 是 9 季、2031 是 4 季;起始年份 2035 以後一開始就只剩「退休」
(實測:起始 2100 的老闆模式沒有「前進下一日」,直接是「退休」)。第一天「擁有時間」還會顯示 Y − 2005 年(起始 2026 顯示
21 Years), 淨值也不一樣(測試者回報沒改年份是 $2,000,000;本站實測起始 2026 是 $940,671,原因本站還不知道)。 想完整玩老闆模式(30 年),請用沒改過年份的執行檔,或改用 時鐘版(王朝跟著電腦日期走,老闆模式維持 2005 起算)。 - 時鐘版(
--dynasty-year clock)另外有幾條:只在一台 Windows 實機驗過(Wine / macOS 沒測); 2100 那一側的夾限只是照程式碼推的、沒有實機測;每一次「開新王朝」都以當下的電腦日期為準,已經存好的王朝不會變; 老闆模式維持 2005 起算,畫面上的年份也是 2005(測試者說可以接受)。細節與條件見第三輪。 - 賽程檔不用另外換。王朝一建立,遊戲就把賽程平移到執行檔設定的年份:本站實測,賽程檔是 2026 年、執行檔沒改 → 畫面是 2005; 賽程檔是 2005 年、執行檔設 2034 或 2100 → 畫面是 2034 或 2100(見「第二輪實測」)。其他模式有沒有一樣,本站沒測。
- 已經存在的王朝存檔不會被改。新年份要開新的王朝才看得到。
- 光碟保護的原版執行檔做不到(兩處都找不到,腳本會停下來)。
👉 你要做的事
指令裡的 python 與 python3
下面範例照 Mac 寫法用 python3;Windows 使用者把每行開頭改成 python。
下載腳本
mvp_modernize.py(2,970 行,零相依)
指令要在放腳本的那個資料夾打。瀏覽器下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列(整條會反白),把字刪掉改打 cmd 按 Enter —— 開出來的命令提示字元已經停在 MVP_Tools 裡,後面的指令都在這個視窗打(第 0 課操作一的「資料夾法」)。Mac:打開終端機,先打 cd ~/MVP_Tools 按 Enter。
先看你手上那份是什麼(完全唯讀)
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe"
會印出架構、記憶體上限、以及那張解析度表的八筆內容。
預覽要改什麼(還是不會動到檔案)
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080
它會先講總共會改幾個位元組,再把位置列出來(位置只列前 16 個,總數以前面那一句為準)。本站在剛安裝好的英文版原版上跑上面這一行,實測是 7 個位元組,7 個位置全部列得出來。
確定了才真的改
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 --apply
會先備份,寫完再讀回來確認改動的位元組剛好是預期那幾個、其他一個都沒動。
(想固定王朝年份,或讓它跟著電腦日期走,才做)把 --dynasty-year 加進同一行
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 --dynasty-year 2026 --apply
年份換成你要的(2005 到 2100)。賽程不用另外換。老闆模式不會跟著變長(上限 2035 年), 限制與原因見上面那一節。
想讓王朝年份跟著電腦日期自動走、老闆模式又維持原版,把 --dynasty-year 2026 換成
--dynasty-year clock(做法與實測見第三輪)。
✅ 成功標準
- 「已備份 → mvp2005.exe.modernizebak」
- 「複驗:改動的位元組剛好是預期那 N 個,其他一個都沒動 ✅」
- 再跑一次唯讀檢查,記憶體上限顯示 4 GB、解析度表第 1、2 筆是你設定的尺寸
- 進遊戲,設定畫面選得到新的解析度
- 有加
--dynasty-year的話:再跑一次唯讀檢查,會多一行「王朝起始年份 2026(程式碼裡寫死的兩處都是 2026)」; 接著開一個新的王朝、選好球隊,看畫面上的日期是不是你設定的年份。 這一條本站已經在一台 Windows 實機驗過(見上面「實測結果」),你的結果(不管對不對)還是請照最下面的回報範本告訴本站。 - 用的是
clock的話:唯讀檢查會多一行「王朝起始年份:跟著電腦時鐘走(…)✅」; 開新王朝,日期的年份應該等於你電腦現在的年份(2005 到 2100 之間);再開一次老闆模式,年份是 2005、選單有「前進至下一日」。
第二次以後再跑 --apply,備份那一行會變成
「備份已存在,保留最早那一份 → mvp2005.exe.modernizebak(5443584 位元組,2026-09-05 20:34)」——
那是正常的:它不會用新的蓋掉你動手前那一份,而且沿用之前會先確認那份備份對得上現在這顆執行檔。
❌ 出錯處理
| 看到什麼 | 怎麼做 |
|---|---|
| can't open file '…mvp_*.py' | 命令視窗跟腳本不在同一個資料夾。
打開放著那個 .py 的資料夾(照第 0 課操作六搬過的話就是 MVP_Tools),在網址列打 cmd 按 Enter,用新開的視窗再跑一次(Mac 用 cd 切過去,例如 cd ~/MVP_Tools)。
還是一樣的話,是檔名對不上:瀏覽器可能在檔名後面加了 (1),把它改成跟指令裡一模一樣 |
| 開頭不是 MZ / 找不到 PE 檔頭 | 路徑指錯了,要指到 mvp2005.exe 本身 |
| 找不到解析度表 | 你這個版本的表長得不一樣。腳本不會亂猜位址,這是刻意的。只有 --resolution 會因此停下來,--4gb 與 --timestamp 不看那張表,照樣做得下去(--pool 也不看那張表,但它在有光碟保護的原版上找不到樣態,而那會讓整行指令停下來 —— 同一行裡的 --4gb 與 --resolution 也不會被套用,要把 --pool 拿掉再跑一次) |
| 找到 N 個看起來像表的地方 | 同上,不確定就不動。回報你的執行檔大小 |
| 大位址旗標本來就開了 | 正常,那一項跳過就好 |
| 複驗說改動的位元組跟預期不符 | 立刻 --restore,然後回報 |
| 這個檔是半截的(讀不完 PE 檔頭) | 檔案沒有複製完整。重新複製一份完整的執行檔再跑 |
| 這份備份跟現在這顆執行檔對不上 | 多半是你換過另一顆執行檔,而舊的備份還留在旁邊。把舊備份改名或搬走再跑;真的要用它,就自己在檔案總管 / Finder 裡把它複製回去,那樣你至少看得到自己在蓋掉什麼 |
| 作業系統不讓我讀寫檔案 | 遊戲裝在 Program Files 這種需要管理員權限的位置、磁碟滿了、或外接碟被拔掉。畫面上沒印出「已寫入」就代表執行檔還沒被換掉 |
| 時間戳只放得下 1970-01-01 到 2106-02-07 | --timestamp 給的日期超出 PE 那個欄位放得下的範圍,換一個 |
| 記憶體池只接受 64 到 1024 MB | --pool 給的數字超出範圍(--pool 0 也會走到這一條) |
| 王朝起始年份只接受 2005 到 2100(或 clock) | --dynasty-year 給的年份超出範圍(--dynasty-year 0 也會走到這一條);要跟著電腦日期走就寫 clock。2100 是本站實測過的最大值,來由見第四個旋鈕那一節的限制卡片 |
| 沒有剛好各找到一個寫死王朝年份的位置 | 最常見是光碟保護的原版(兩處都找不到),這一項做不到;
找到不只一個則是腳本不敢猜。這會讓整行指令停下來,同一行的 --4gb 等也不會被套用 —— 把 --dynasty-year 拿掉再跑一次 |
| 沒辦法把這顆改成時鐘版 | 後面會寫是哪一個前提不成立(匯入表沒有 LoadLibraryA / GetProcAddress、最後一個區段放不下洞穴、兩支模式判斷的結構對不上…)。
這一族之外的執行檔做不到時鐘版;整行指令會停下來,同一行的 --4gb 等也不會被套用。要固定年份仍然可以用數字 |
| 設年份那處已經被改成「呼叫別處的程式」,但那段程式不是本工具做的 | 別的工具動過同一處,這支腳本不知道那段程式在做什麼所以不碰。有備份就 --restore,或拿沒改過的執行檔重做 |
| 體檢說「王朝起始年份:兩處不一致」 | 有人只改了其中一處。再跑一次 --dynasty-year 你要的年份 --apply,腳本只會補齊不一致的那一處 |
一行就能還原
python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --restore
Windows 把 python3 換成 python。
本站實測:兩份剛安裝好的原版(英文版與中文版)各改各的、再還原, SHA-256 都跟動手前完全相同。
⚠️ --restore 有可能拒絕還原:備份跟現在這顆執行檔對不上的時候
(最常見的是你換過另一顆執行檔而舊備份還留著),它會停下來並告訴你怎麼辦。
那是刻意的,不是工具壞了。
Windows 10 / 11
- 32 位元程式在 64 位元的 Windows 上照樣能跑。 微軟到今天仍然支援,這不是問題。(真正被拿掉 32 位元支援的是 macOS,見下一節。)
- 2 GB 那道牆在 64 位元 Windows 上其實可以拉高 —— 前提是執行檔本身有開大位址旗標。那是執行檔的屬性,不是設定選項。 本站不提供任何別人改好的執行檔;要開這個旗標,就用這一課上面那支 mvp_modernize.py 改你自己那一份 —— 它預設是唯讀的,也可以只檢查不改。
- 先確認遊戲本體是完整的:照 怎麼看出哪些檔還沒被動過那一課量一遍。 很多「跑不起來」其實是裝了半套模組。
- 當掉的話別亂試:遊戲當掉了怎麼辦 教你先分辨是哪一種當機。
Mac:Apple Silicon 也跑得動,而且本站實測過
Mac 這邊的難點跟 Windows 完全不同:
- macOS 從 Catalina 之後完全拿掉了 32 位元支援
- Apple Silicon 是 ARM 架構,而遊戲是 x86 的
看起來是兩道死牆,但實際上兩層轉譯疊起來就通了:
你的 Mac(Apple Silicon,ARM)
└─ Rosetta 2 把 x86_64 翻成 ARM
└─ Wine(x86_64 的 Mach-O) 把 Windows 的呼叫翻成 macOS 的
└─ 32 位元的 Windows 程式 ← 遊戲在這一層
本站實測到什麼
測試機:Apple M4 · macOS 26.5.2 · arm64,
用 brew install --cask wine-crossover 裝的 Wine 8.0.1。
這個套件現在已經拿不到了(2026-04 起從它所在的第三方來源移除),
這一行留著是記錄當時實測用的是什麼,不是叫你照打;現況見
在 Mac 上玩 MVP 2005那一課第 1 步。
| 驗的是什麼 | 結果 |
|---|---|
| Wine 執行檔本身的架構 | Mach-O x86_64(所以是透過 Rosetta 2 跑的) |
| Wine 能不能起來 | ✅ 建得出環境,wine cmd /c echo 有回應 |
| 有沒有 32 位元的 Windows 環境 | ✅ syswow64 有 779 個檔 |
| 32 位元程式真的執行得了嗎 | ✅ 拿 syswow64 裡的 32 位元 cmd.exe(PE 檔頭確認是 0x014C)
跑一句 echo,有印出來 |
⚠️ 說清楚驗到哪裡為止:本站驗的是「這台 Mac 能執行 32 位元 Windows 程式」這件事, 不是「遊戲全程順跑」。遊戲還會用到 3D 繪圖與音訊,那些本站沒有逐項測。 所以這一節寫的是路是通的,不是保證你不會遇到問題。
⭐ 它要的是 Direct3D 8,不是 9
上面那句「3D 繪圖那些本站沒有逐項測」,現在補上一半 —— 它到底要哪一層繪圖執行期,執行檔自己就寫著。
PE 檔頭裡有一張匯入表,列出這支程式開機時要跟哪些系統元件要函式。 剛安裝好的官方英文版,那張表有 17 個 DLL, 跟畫面、輸入、聲音有關的是這五個:
| 元件 | 它跟這個元件要的函式 | 是什麼 |
|---|---|---|
| D3D8.DLL | Direct3DCreate8 | Direct3D 8 的進入點 |
| DDRAW.DLL | DirectDrawCreate | DirectDraw(2D) |
| DINPUT.DLL | DirectInputCreateA | 手把與鍵盤 |
| DSOUND.DLL | (第 1 號) | DirectSound |
| WINMM.DLL | timeGetTime | 高精度計時 |
整支執行檔裡,d3d9 與 d3dx9 出現 0 次。
反過來 Direct3DCreate8 出現 2 次
(對照組:DirectDrawCreate、d3d8.dll、DSOUND 也各 2 次,
所以搜法有效)。
第二條獨立指標
檔案裡有一小段固定樣板,把這幾個字串擠在一起:
Software\Microsoft\Direct3D DisableMMX DisableD3DXPSGP
d3d8d.dll DebugSetMute d3d8.dll
那是 D3DX8 輔助函式庫的標準開場:先去找除錯版的執行期
(d3d8d.dll),找不到才退回正式版。
這是 DirectX 8 的 SDK 才有的東西。
順帶:PE 檔頭的連結器版本是 7.10, 也就是 Visual C++ 7.1(Visual Studio 2003)編的 —— 跟 2005 年出貨的時間對得上。
這對你有什麼用
在 Windows 上:DirectX 8 的執行期已經包在 Windows 裡, 不必另外裝。工具箱裡那些 DirectX 9 安裝檔不是為了這款遊戲的本體裝的。
在 Mac 或 Linux 上:Wine 這類相容層要準備的是 Direct3D 8 那一層。畫面出問題的時候,該查的是 D3D8 的支援狀況, 不是 D3D9。
⚠️ 本站量到的是「這支程式跟系統要什麼」, 不是「它跑起來畫面對不對」。後者要實機測,本站在 Mac 上跑到啟動畫面為止。
⭐ 檔名會騙人 —— 那一排 mvp2005 - XXX.exe 到底哪個是哪個
玩久了資料夾裡常常會有一排執行檔,檔名寫著「4GB」「1080p」「2K」「4K」。 本站把手邊 8 個逐位元組比對過, 結論是:只信檔名會裝錯,只有比對看得出來。
先講一件事:本站的腳本改一個執行檔只動兩個地方,一共十幾個位元組 —— 4GB 那 5 個位元組(大位址旗標 + 檔頭校正),加上解析度表第 1、2 筆。
⚠️ 2026-09-05 訂正:這裡原本接著寫「其餘一個位元組都沒碰」,
那句話對下面這幾顆不成立 —— 它們是別人改的,不是本站的腳本改的。
本站逐位元組比過:社群那三顆(更新4GB / 2K / 4K)
對原版差 14 到 16 個位元組,多出來的 0x247、0x304、0x30C
是節區旗標,不是程式碼;那顆素面的 mvp2005.exe 是本站自己調過的,差得更多。
所以下面這張表只拿大位址旗標與解析度表第 1 筆兩欄來分辨,
不宣稱其餘位元組沒被動過:
| 檔名寫的 | 大位址(4GB) | 解析度表第 1 筆 | 真相 |
|---|---|---|---|
原版(檔名寫著「原版」) | 沒開 | 640×480 | 沒改過的原始執行檔。⚠️ 它跟最後一列那顆真的沒有後綴的 mvp2005.exe 不是同一個檔 —— 這張表最容易被自己騙的就是這裡 |
更新4GB | 開 | 1920×1080 | ⚠️ 檔名只說 4GB,其實解析度也改成 1080p 了 |
已更新4GB及1080p | 開 | 1920×1080 | ⚠️ 跟上面那個「更新4GB」逐位元組完全相同 |
已更新4GB及2K | 開 | 2048×1080 | 真的是 2K(2048 寬) |
已更新4GB及4K | 開 | 3840×2160 | 解析度確實是 4K。⚠️ 2026-08-28 量的時候,它跟那個沒有後綴的 mvp2005.exe 逐位元組完全相同;今天已經不是了 —— 那顆素面的後來被換成本站自己調過的版本,兩顆差 14 個位元組(SHA-256 不同,連檔頭校驗值都不一樣)。這正好是本節要講的事:檔名跟舊筆記都會過期,檔案內容不會 |
mvp2005.exe(沒後綴) | 開 | 3840×2160 | 其實就是 4K 版 |
當時手邊 8 個檔,其實只有 6 種不同的二進位。兩對是重複的:
「更新4GB」=「1080p」(這一對 2026-09-05 重新量過,仍然相同),
以及「4K」=那個素面的 mvp2005.exe(這一對今天已經不成立,見上表的訂正)。
而「更新4GB」這個檔檔名只承認改了記憶體,實際上連解析度都動了 ——
裝的人如果只想要 4GB、不想改解析度,光看檔名會挑錯。
另一個檔名的坑
本站還看到一個資料夾叫 4GB-Patch_1920x1024,
裡面那個執行檔的解析度表寫的其實是 1920×1080,不是 1024。
1024 是打字打錯的。
⭐ 這一段的教訓跟本站很多頁一樣:檔名是人寫的,會過期、會手滑、會只講一半。 檔案內容不會。要確認一個執行檔是什麼,用 最上面速解裡那支腳本的唯讀檢查看它的大位址旗標與解析度表, 不要相信檔名。
⚠️ 本站不散布這些執行檔,也不教你怎麼繞過任何保護。 這一段講的純粹是「怎麼看懂你自己已經有的那幾個檔差在哪」 —— 每一個位元組都可以用上面那支唯讀腳本自己驗。
一個看起來像答案、其實不是的東西
本站在剛安裝好的官方中文版裡找到一份 mvp.ini:
472 行,EA 自己寫滿註解,裡面明明白白寫著怎麼覆寫螢幕解析度、
怎麼解鎖全部隱藏內容、怎麼顯示球場記憶體用量。
; over-ride the default screen resolution ; the width, height, and colour depth are ignored if override_default_screen_resolution is 0 override_default_screen_resolution = 0 default_screen_width = 1024 default_screen_height = 768
看起來完美。但遊戲不讀它。
本站拿三個執行檔(剛安裝好的英文版、剛安裝好的中文版、測試機那顆)逐一搜過:
override_default_screen_resolution 找不到、
default_screen_width 找不到、連 .ini 這三個字元都是 0 次。
而對照組成立:同樣的搜法搜 data、.big、attrib、stadium
全部找得到(各 51 / 70 / 121 / 21 次),證明執行檔裡的字串是讀得到的、搜法沒問題。
再看時間戳:mvp.ini 是 2005-02-09,執行檔是 2005-02-16 ——
設定檔比程式早一週。合起來看,它是 EA 開發時期留下來的東西,出貨的遊戲沒有在讀。
為什麼把這一段寫出來
因為它是本站差一點寫錯的一課。一份 472 行、寫滿註解、每個選項都正好是你想要的檔案 —— 它最危險的地方就是「看起來太像答案」。
分辨的方法很單純:去程式裡找它讀不讀這個檔,而且一定要跑對照組。 沒有對照組的話,「搜不到」跟「我搜錯地方」長得一模一樣。
官方中文版跟英文版不是同一份加翻譯
本站 2026-08-28 拿兩份剛安裝好的原版逐檔比對,發現的差異比預期大得多:
| 英文版 | 官方中文版 | |
|---|---|---|
| 檔案總數 | 1,191 | 2,389 |
data\datafile\ | datafile.big(封裝) |
466 個散裝 .txt,沒有封裝檔 |
data\anims\ | anims.big(封裝) |
730 個散裝檔,沒有封裝檔 |
mvp.ini | 沒有 | 有(但遊戲不讀,見上一節) |
這件事有實際後果:本站改攝影機那一課
原本寫死了要去 datafile.big 找,裝官方中文版的人從第一步就卡住。
那個問題已經修好了(工具會自動改用散裝檔),但本站把它記在這裡,因為原因值得講:
本站在此之前所有的量測都是拿英文版做的 —— 不是因為判斷過哪個比較好,而是因為手邊那份剛好是英文版。 這叫取樣偏誤:不是資料錯,是「你只量了你手上有的那一份」。 站上其他頁的數字如果標的是英文版,本站會照實寫清楚。
📋 回報範本
照著做還是跑不起來的話:
跑不起來 狀態 作業系統:Windows 10 / 11 / macOS(版本) Mac 的話:晶片是 Intel 還是 Apple Silicon 診斷腳本說架構是: 診斷腳本說大位址旗標: 卡在哪一步: 畫面上的訊息:
📌 重點整理
- 一句話 這是 2005 年為 Windows XP 做的 32 位元程式(
0x014C),剛安裝好的原版沒有開大位址旗標,所以最多只能用 2 GB,而這一課改的記憶體上限與螢幕解析度都只是相容性修正,跟光碟檢查、授權驗證完全無關。 - 記憶體有兩層天花板 一層是位址空間,32 位元指標最多數到
2³² = 4,294,967,296,不是 EA 沒做,是數學上不可能,要 8 GB 只有把整個程式重新編譯成 64 位元一條路,那要原始碼;另一層是遊戲自己跟系統要的那塊 64 MB 池子(--pool),房間變大櫃子沒變大,中文版配大球場會跳出卡的是它,套了 4GB 也沒用(這一項在光碟原版上因為帶有光碟防拷保護而找不到,但 4GB 與解析度照樣可以改 —— 要把--pool從那一行拿掉,不然整行會停在這一關)。 - 「球場不能超過 20MB」被原版自己推翻了 那條規矩站上有兩個數字,而它們貼在不同檔案上。
data\frontend\stadiums.big剛安裝好就已經 28.77 MiB(30,162,721 bytes,十進位寫法是 30.2 MB,本頁兩張表各用一種口徑),比那條上限多 44%;「10MB」指的是各別球場檔(原版最大 3.99 MB、社群最大 18.44 MB),但沒有人量過邊界。 - 裝了模組會跳出,先換這兩個檔 把
uniforms.big與stadiums.big換回原版,等於少載 28 MB,那正是班主任機器上會不會當的分界。反例很關鍵:models.big大了 389 MB 卻一直沒事,所以「總量太大」這個解釋不成立。 - 最容易踩的雷:檔名會騙人 手邊 8 個
mvp2005 - XXX.exe逐位元組比對,其實只有 6 種不同的二進位。「更新4GB」那顆連解析度都改成 1080p 了,還有一個資料夾叫4GB-Patch_1920x1024而裡面其實是 1920×1080。要確認一顆執行檔是什麼,跑唯讀檢查看內容。 - 改了解析度,畫面還是被拉伸的 那張表只告訴遊戲「有多少像素」,沒告訴它「像素的形狀變了」,內部投影比例仍然是 4:3。真正修比例要靠第三方的
mvp2005fix,它在執行時攔投影矩陣,跟本站的腳本改的是不同筆(本站改第 1、2 筆 640×480,它找第 3、4 筆 800×600),可以並存。 - 安全網有三層 不加
--apply只預覽,先講總共會改幾個位元組;--apply先備份成mvp2005.exe.modernizebak(已經有備份就沿用最早那一份,沿用前會先確認它對得上現在這顆執行檔)再改,改完自己複驗,不符就把還原指令印給你,照著立刻還原並回報;--restore一行退回,兩份原版實測還原後跟動手前完全相同。寫到一半斷掉、按了 Ctrl-C、檔案是捷徑時它怎麼保護你的檔,見這支腳本在做什麼。 - 換了執行檔,王朝又變回 2005 因為程式碼裡有兩處寫死的 2005(王朝建立時把年份設成 2005、以及一個 3 月 26 日的固定日期),改賽程或用社群小工具都沒碰到它們,而重新走 4GB 流程做出來的新執行檔沒有帶著手改的結果。洗回 2005 只發生在建立新王朝,已存的王朝換執行檔不會變。解法是
--dynasty-year 2026,跟--4gb寫在同一行;範圍 2005 到 2100,賽程檔不用另外換;老闆模式不會跟著變長(上限 2035,起始 2026 只剩 9 季),想完整玩請用沒改年份的執行檔;一台 Windows 實機驗過新王朝顯示新年份、起始 2034 能打進 2035、起始 2100 能打進 2101。 - 想讓王朝年份跟著電腦日期走 把數字換成
--dynasty-year clock:開新王朝讀電腦當下的年份(夾在 2005 到 2100),老闆模式維持原版 2005。一台 Windows 實機驗過;2100 那一側的夾限、Wine / macOS 都沒測。 - 本站沒驗的 本站從來沒有量過遊戲執行時到底吃多少記憶體,所以不列任何分配數字,也答不出 4 GB 夠不夠、球場放多大才會當。
mvp2005fix本站只讀過原始碼、量過自己的執行檔,沒有實際跑過。Mac 那一節只有一台機器的一次實測:2026-10-29 在本站的 Mac 測試機(Wine)上,1080p 版的mvp2005.exe(SHA-256 前 16 碼7f0715f95481009f)開過標題、展示賽、主選單,並在一個設定檔下開了一場 Play Now、進到場上再離開,沒有當機;這個結果只綁這個 SHA,換了執行檔就視為沒測。走比賽設定畫面時,遊戲自己會改寫MVPOptions_Options.sav(大小不變、時間戳變了),動手前先備份。
這支腳本在做什麼
整支的流程是一條直線:把執行檔整份讀進記憶體 → 算出 PE 檔頭那幾個欄位在哪裡 →
用內容找出解析度表與記憶體池 → 在記憶體裡的複本上改 → 把要改幾個位元組、位置在哪列給你看 →
有 --apply 才備份、寫檔、重新讀回來對一次。
沒有 --apply 的時候,這幾個旋鈕(--4gb、--resolution、--pool、--timestamp、--dynasty-year)永遠停在「往螢幕印字」這一步,一個位元組都不會落地。
--restore 不在這一句的範圍裡:它是另一條路,不看 --apply,
備份通過覆蓋前那三道把關之後,會先把備份寫成同一個資料夾裡的一個隨機暫存檔、
讀回來比對 SHA-256,過了才用改名把它換上去 —— 改名之前中途斷掉
(磁碟滿、外接碟拔掉、按了 Ctrl-C)你的執行檔一個位元組都不會被碰到,
而改名本身是原子的,不會留下寫到一半的執行檔。
改名跑完之後才按下去的那一下,腳本不會跟你說沒動到 —— 它會說
「還原那一步已經做完了」,因為那時你的執行檔確實已經是備份的內容
(本站拿變體檔在改名之後注入 Ctrl-C 實測,結束碼 130,畫面印的就是這一句)。
本站把測試機那顆 mvp2005.exe(它已經被調過 —— 大位址開著、解析度表第 1 筆是 3840×2160,
就是上面「檔名會騙人」那張表最後一列那顆,不是剛安裝好的原版)
複製一份實測,只下 --restore、沒有加 --apply,
那份複本的 MD5 當場變成備份的 MD5。
修改球員跑壘速度那一課的腳本也是一樣的結構。
值得先知道的是它只換不加:每一個旋鈕都是原地覆寫, 檔案大小前後一模一樣,沒有插入也沒有刪除。也因為這樣, 它才有辦法在寫完之後說出「改動的位元組剛好是預覽算出的那份完整清單,其他一個都沒動」。
| 哪一段 | 做什麼 | 為什麼要有它 |
|---|---|---|
main() |
看你打了哪些開關,分派到三條路:還原、修改、唯讀體檢。 | --restore 排在最前面,
因為出事的時候那一行一定要能跑。三條路互斥,
所以「以為在改、其實只是看」不會發生。 |
load() /
describe() |
整份讀進記憶體,把架構、大位址旗標、校驗值、編譯時間戳、 記憶體池與整張解析度表印出來。 | 預覽跟真的要寫,呼叫的是同一支 describe(),
所以你看到的那份資料,就是等一下拿去改的那份。
它只讀不寫,找不到記憶體池或解析度表也只是少印一段,不會整支停擺。 |
pe_offsets() |
算出 PE 檔頭在哪、Characteristics 在哪、
CheckSum 在哪。位移 0x3C 起的 4 個位元組指向 PE 檔頭,
從 PE 檔頭起點往後 +22 是 Characteristics(不是從
COFF 檔頭起點算,兩者差 4 個位元組),+24 之後再 +64 是 CheckSum。 |
PE 的排法是固定的,這三個位置用算的就好,不必去搜。
但只要開頭不是 MZ、或 PE 簽章不在它該在的地方,就直接停:
在看不懂的檔案上算位址,等於閉著眼睛寫。 |
find_table() |
用內容找解析度表,不用寫死的位址。 一筆 16 個位元組、四個 32 位元小端序數字(寬、高、色深、保留),共 8 筆。 | 不同版本的位址不一樣(本站實測英文版原版在
0x4C3FD8、中文版原版在 0x52F860)。
特徵刻意取後四筆(1024×768 與 1280×1024),
因為要換掉的是最前面那兩筆,拿它們當特徵,改完就再也找不到自己改過的表。
找到不只一處也一樣停下來,不用「第一個」湊答案。 |
find_pool() |
找遊戲自己那塊記憶體池。認的是一段機器碼的形狀: push 一個 32 位元立即數、call、pop ecx、ret。 | 光靠形狀會撞到一堆不相干的地方,所以再要求那個數字
「剛好是 1 MB 的整數倍,而且落在 64 MB 到 1024 MB 之間」。
本站在測試機那顆 mvp2005.exe 上通常會找到兩處:
push 那個位元組在 0x3736F0 與 0x3CDD08,
64 MB 改成 128 MB 真正動到的是最高位那一格,也就是
執行檔上的六個旋鈕那一課列的
0x3736F4 與 0x3CDD0C。
兩處的 push 五個位元組完全相同,後面 call 的相對位移不同,
但算出來的目標是同一個位址 0x3CDCCC;
把整個 .text 的 E8 相對呼叫掃過一遍,
0x3736F0 有 1 個直接呼叫者、
0x3CDD08 是 0 個,
兩個位址也都沒有以 4 位元組常數出現在檔案的任何地方。
這是純位元組比對的結果,沒有反組譯去確認 0x3CDD08 有沒有間接呼叫,
所以本站只講到「1 比 0」為止,不說哪一處是死碼。
兩處本來就是同一個值,一起改最保險。
有光碟保護的原版,這裡本來就找不到,那不算錯。 |
pe_checksum() |
重算 PE 檔頭的校驗值:每 2 個位元組當成一個小端序數字加起來、 進位摺回,最後加上檔案長度。算的時候把校驗值欄位自己當成 0。 | 兩份剛安裝好的原版這一欄都是 0,
EA 沒有填,Windows 對一般程式也不檢查。所以規則是「原本是 0 就維持 0,
原本有值才重算」,不會替你在空欄位裡填一個新東西進去。 |
apply_changes() |
在記憶體裡的複本上動你選的旋鈕,然後逐位元組比出 「到底哪幾個位置變了」,列出來(畫面上只列前 16 個, 拿去複驗的是完整清單)。 | 改動清單不是自己記帳記出來的,是拿原件跟複本比出來的, 所以不會漏。每一個旋鈕還會先看「現在是不是已經是你要的值」, 是的話就說一聲不用做,不會白寫一次。 |
| 寫入後的複驗 | 寫完立刻重新讀出來,比對檔案長度沒變, 而且實際改動的位元組剛好等於預覽算出的那份完整清單 (比的是完整清單,不是畫面上列出來的前 16 個)。 | 這是唯一能證明「沒有多改到別的東西」的方法。
多一個少一個都當成失敗,畫面會把那一行 --restore 指令
連路徑一起印出來給你直接複製,而且結束碼不是 0。 |
_atomic_copy() |
第一次寫入前備份成 mvp2005.exe.modernizebak:
先整份寫成同一個資料夾裡一個隨機名字的暫存檔(名字事先猜不到,不會被同名的捷徑騙去寫別的地方),完成了才改名。 |
改名在同一顆磁碟上是原子的,所以備份那個檔名 要嘛還不存在、要嘛就是完整的一份。中途斷掉(磁碟滿、外接碟拔掉、按了 Ctrl-C)不會留下半截備份。備份那個檔名要是被放成一條捷徑(符號連結), 這一步會直接停下來,不會順著它把資料夾外面的檔覆蓋掉。 已經有備份就保留最早那一份;而且沿用之前會先確認它 確實是現在這顆執行檔的備份(大小一樣,而且兩份不同的位元組 全部落在這支腳本會動的那幾格裡),對不上就停下來, 不在沒有正確備份的情況下動你的檔。 |
_restore_from_backup() |
--restore 真的覆蓋之前有三道:開頭要是 MZ;
這份備份要對得上現在這顆執行檔;
再走一次備份的 PE 節區表,確認「每個節區的位移加長度」的最大值
沒有超出檔案本身的長度。 |
半截備份的開頭照樣是 MZ,只看開頭看不出後面少了多少。
本站實測過一份被截成八分之一的備份:當時的還原把關全過,
還印了「內容與備份相同」,然後把正本吃掉,而那會讓遊戲完全開不起來。
把關全過之後真正覆蓋的那一步也不是直接寫回去:先寫成同一個資料夾裡的
隨機暫存檔、把原本的權限抄過去、再讀回來比 SHA-256,全部相同才改名換上,
所以「隨時可以 --restore」這句話中途被中斷也還站得住。 |
安全網有三層:唯讀是預設、寫入前備份、寫入後重新讀出來複驗。
備份那一層還多一道把關:沿用舊備份之前會先確認它確實是現在這顆執行檔的備份,
對不上就停下來。
還有一道跟這三層平行的:要寫進去的那兩個檔 —— 遊戲執行檔本身、備份檔 ——
只要有一個是捷徑(符號連結),--apply 與 --restore
就直接停下來,結束碼不是 0,不跟著捷徑寫到資料夾外面去;
唯讀體檢與預覽只讀不寫,走在捷徑上照樣看得下去。
本站實測:把 mvp2005.exe 換成一條指向資料夾外面的捷徑再 --apply,
腳本擋下來、外面那顆的 SHA-256 一個位元組都沒變;改回 2026-09-06 之前那種
「跟著捷徑走」的寫法,同一個餌就會把外面那顆改掉、畫面還照樣印成功 ——
所以這道守門是載重的,不是裝飾。
做不到的事也講在原始碼開頭:不能把 32 位元變成 64 位元(4 GB 是指標數得到的上限,
不是哪裡有設定值可以調大);有光碟保護的原版找不到記憶體池的樣態,
而這支腳本不會、也不打算去碰那一層;改完解析度表畫面還是被拉伸,
那是遊戲內部投影比例的事,不在這支的範圍;
本站也沒有量過遊戲執行時實際吃多少記憶體,所以它不會回答你「球場可以放多大」。
想先確認腳本本身沒壞,可以跑 python3 -I mvp_modernize.py --selftest
(Windows 打 python)。它不需要執行檔:在系統暫存資料夾照 PE 格式拼一顆 8 KB 的假執行檔,
把上面每一道安全網各下一個餌驗一次,跑完整個刪掉,不碰你的遊戲。
全過回 0,有一道沒過回 1。
完整原始碼
下面就是你剛才下載的那一支,一個字都沒有不同(本站有自動檢查在守這件事)。
展開 / 收合完整原始碼(2970 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_modernize.py — 讓 MVP Baseball 2005 在今天的機器上跑得順
這支腳本處理三件事,三件都是改**你自己那份**遊戲執行檔:
1. 記憶體上限:這款遊戲是 2005 年的 32 位元程式,預設只能用 2 GB。
執行檔的 PE 檔頭有一個叫「大位址」的旗標,打開之後在 64 位元的
Windows 上可以用到 4 GB。
⚠️ 社群傳很久的「球場不能超過 20MB」,本站**不**把那 2 GB 的牆
當成它的根因,因為那條規矩本身就沒站住。本站測試機上剛安裝好、
從來沒被動過的原版(英文版與繁體中文版各一份,兩份同值),
frontend 資料夾裡的 stadiums.big 就已經是 30,162,721 bytes
(28.77 MiB),比 20MB 多 44%,等於 EA 自己出貨就「超標」。
另一個流傳的數字「10MB」貼的是 stadium 資料夾裡的各別球場檔
(同一台機器上量到:兩份原版各 87 個檔、最大 4,184,449 bytes;
站上標「10MB」的那包社群球場 83 個檔、最大 19,333,365 bytes),
而放多大會當、放多小不會當,沒有人量過。
另外,中文版配大球場跳出這件事,本站量到卡住的是遊戲自己那塊
記憶體池(下面的 --pool),不是 2 GB 的位址空間。
2. 螢幕解析度:遊戲內建的解析度表只到 1280x1024(2005 年的常見上限)。
那張表就在執行檔裡,一筆 16 個位元組:寬(4) 高(4) 色深(4) 保留(4),
全部是 32 位元小端序。把其中一筆換成 1920x1080 之類的現代尺寸就行。
3. 王朝模式的起始年份(2026-10-29 加):王朝模式一建立,遊戲就把年份設成
2005 —— 這個 2005 是寫死在程式碼裡的。所以你用社群小工具或補習班那一課
把賽程換成別的年份之後,只要換過一次執行檔(最常見的是重新走一遍
4GB 流程,做出一顆沒有改過年份的新執行檔),王朝又會被洗回 2005。
--dynasty-year 把程式碼裡兩處寫死的 2005 換成你要的年份:
· 一處是「模式成立就把年份設成 2005」(王朝建立時走這一處)
· 一處是「3 月 26 日、2005 年」這個固定日期(不是王朝的模式拿它當
算球員年齡的基準日)
兩處都是用內容找(不記位址),各自要剛好找到一個才動手。
本站在測試機上量到的 8 種內容不同的變體(原版、
4GB、各種解析度、記憶體池不同的)兩處都剛好命中一次,年份都是 2005;
另有一顆別人手改過的,兩處都是 2026,和這支腳本做出來的位元組一模一樣。
⚠️ 現行範圍是 2005 到 2100(2100 是實測過的最大值;理由見 YEAR_MAX 的說明)。
⚠️ 老闆模式(Owner Mode)不會跟著變長:它的上限是另一個寫死的 2035 年,見下面「限制」。
✅ 實測(2026-10-29,一台 Windows、一份賽程已是 2026 年的遊戲資料夾):
沒改年份的執行檔開新王朝顯示 2005,用這支腳本改過的顯示 2026,
和一顆手改過兩處的執行檔結果相同;用改過的那顆建好的存檔,換沒改年份的
執行檔去載入,年份仍是 2026 —— 洗回 2005 只發生在「建立新王朝」,不是載入。
✅ 第二輪實測(2026-10-29,同一位測試者,遊戲資料夾裡的賽程檔是 2005 年的、沒有另外換):
起始年份 2034 的王朝打完一季進到 2035,沒被擋、沒當機;起始年份 2100 的王朝開得起來、
日期與星期都對、整季打完進到 2101。
沒測的:起始年份 2101 到 2123、非王朝模式的球員年齡、光碟保護的版本與中文版。
✅ 第三輪實測(2026-10-29,同一位測試者):--dynasty-year clock —— 王朝的起始年份不寫死,
改成「開新王朝的當下讀電腦的日期」。電腦日期是當時的 2026 → 開出 2026;把 Windows 日期
改成 2030 → 開出 2030;改成 2001 → 夾回 2005;老闆模式維持原版 2005 起算
(「前進至下一日」照常、組織資料「<1 Year」、淨值 $2,000,000);舊存檔載得進去。
做法:甲處那條 `push 年份` 換成 `call 洞穴`,洞穴是 133 個位元組的小段機器碼,
放在檔案最後一個區段的空白尾端(那個區段本來就可執行、檔案裡就有,不加區段、不改檔案大小),
用執行檔本來就匯入的 LoadLibraryA + GetProcAddress 去找 GetLocalTime;
模式是 13(老闆模式)就照原版 push 2005。乙處(不是王朝的模式算年齡的基準日)維持 2005。
打過時鐘的檔再給 --dynasty-year clock 會說「本來就是」;給數字會拆回寫死(洞穴清成 0,
和直接從沒打過時鐘的檔改成那個年份逐位元組相同);--restore 照樣還原得回去。
⚠️ **這些事都跟光碟檢查、授權驗證完全無關。**
本站不提供、不教學、也不包含任何規避技術保護措施的功能。
這支腳本只動上面講的那幾個位元組,其他一個位元組都不碰。
⚠️ **位址不寫死。** 不同版本的執行檔位址不一樣(本站實測:
英文版原版的解析度表在 0x4C3FD8、中文版原版在 0x52F860)。
所以腳本是**用內容去找**那張表,不是記位址。
── 這支在做什麼(不讀程式碼也看得懂的版本)─────────────────
輸入:一個參數,你那份遊戲執行檔的完整路徑(mvp2005.exe)。
除了這個檔,它不讀遊戲裡的任何其他東西,也不連網。
輸出:
· 什麼開關都不加 = 唯讀體檢,只把讀到的東西印在畫面上。
· 加了 --4gb / --resolution / --pool / --timestamp / --dynasty-year 但沒加 --apply
= 預覽,先講總共會改幾個位元組,再把位置列出來,一樣不寫檔。
位置只列前 16 個,總數以前面那一句為準(本站在一顆找得到記憶體池、
而且 CheckSum 原本就有填的 mvp2005.exe 上,四個旋鈕連 --timestamp 一起開
量到 17 個,畫面就只列得出前 16 個)。
· 加了 --apply 才真的寫。全部是原地覆寫,檔案大小前後一模一樣。
· --restore 把備份蓋回去。
· --selftest(2026-09-24 加)不需要執行檔:在系統暫存資料夾造一顆 8 KB 的假執行檔,
把下面每一道安全網各下一個餌驗一次,跑完整個刪掉,不碰你的遊戲。
全過回 0、有一道沒過回 1、在 python3 -O 底下拒跑回 2。
五個旋鈕各自動到哪裡(沒有插入也沒有刪除,都是原地換掉幾個位元組):
--4gb PE 檔頭 Characteristics 裡的 1 個位元(0x0020)
--resolution 解析度表最前面那兩筆的寬與高,各 8 個位元組
--pool 遊戲自己那塊記憶體池的 imm32,每一處 4 個位元組
--timestamp PE 檔頭 TimeDateStamp 的 4 個位元組
--dynasty-year 程式碼裡兩處寫死的 2005,每一處是 16 位元年份的 2 個位元組
(年份相差不到 256 時其中一個位元組本來就一樣,實際換掉的更少)
--dynasty-year clock 甲處 5 個位元組(push 年份換成 call 洞穴)、洞穴 133 個位元組
(最後一個區段的空白尾端)、乙處 2 個位元組(維持 2005)
原本就有填 CheckSum 的執行檔會再多改那 4 個位元組(原本是 0 就維持 0)。
加 --keep-checksum(2026-10-29 加)連這 4 個位元組也不動,原本寫什麼就留什麼 ——
給「那個欄位放的本來就不是真的校驗值」的檔用;Windows 對一般程式不檢查它,
所以重算與不重算,遊戲都照開。預設行為不變。
安全網有三層:
1. 唯讀是預設,但這一句只涵蓋五個旋鈕(--4gb / --resolution / --pool / --timestamp / --dynasty-year):
它們沒有加 --apply,一個位元組都不會落地。
--restore 不在這一句裡。它是還原路徑,不看 --apply,覆蓋前那三道把關過了
就把備份整份蓋回執行檔;那三道寫在這一段的最後一句。
2. 第一次 --apply 之前先備份成「原檔名 + .modernizebak」,
而且是先寫一個隨機名字的暫存檔、整份複製完成才改名,中途斷掉不會留下半截備份;
已經有備份就保留最早那一份,不會被後來的蓋掉 —— 但會先確認那份備份
確實是**現在這顆執行檔**的備份(大小一樣、兩份不同的位元組全部落在
這支腳本會動的那幾格裡),對不上就停下來,不在沒有正確備份的情況下
動你的檔。寫入本身也是先寫一個隨機名字的暫存檔(開在同一個資料夾裡,
名字事先猜不到,所以不會被人拿一條同名的捷徑騙去寫別的地方),
寫完才改名,中途出事會把暫存檔收掉,
而且改名之前把原本那顆的權限抄過去(不然執行檔會掉可執行位元);
碰到不讓你設權限的檔案系統(FAT32 隨身碟之類)會印一行告訴你,
照樣把改好的內容寫下去。
3. 寫完立刻重新讀回來,比對「實際改動的位元組是不是剛好等於預覽算出的那份完整清單」
(比的是完整清單,不是畫面上列出來的前 16 個),
多一個少一個都要你立刻 --restore。
--restore 也不是無條件覆蓋,覆蓋前三道:開頭要是 MZ;這份備份要對得上
現在這顆執行檔(大小一樣、不同的位元組全部落在上面那幾格裡 —— 擋掉
「換過執行檔卻沒刪舊備份」拿另一顆蓋掉現在這顆);備份的 PE 節區表指到的
長度不得超出檔案本身,擋掉半截備份把正本吃掉(本站實測過一份被截成八分之一的
備份,當時的還原把關全過,還印了「內容與備份相同」)。
三道任何一道沒過都不覆蓋,而且 exit code 不是 0。
三道都過了才寫,而寫法跟上面一樣:先寫一個隨機名字的暫存檔(同一個資料夾),
fsync 落地、權限抄過去、再讀回來跟備份比對 sha256,全部相同才原子改名換上去。
改名之前任何一步不成,就把暫存檔收掉,你那顆執行檔一個位元組都沒被碰過。
(要寫進去的那兩個檔 —— 遊戲執行檔本身、備份檔 —— 只要有一個是符號連結
(捷徑),--apply 與 --restore 就直接停下來,不跟著連結寫到資料夾外面去;
唯讀體檢與預覽只讀不寫,照樣看得下去。)
(按 Ctrl-C 的時候,畫面上說「動了沒」的那一句是照三態登記寫的:
改名還沒開始才會說「一個位元組都沒有動到」;改名跑完會說「已經換好了」,
修改那條路還會附上還原指令,還原那條路則是說「還原那一步已經做完了」;
剛好卡在中間會說「可能已經被換掉了」。改名與登記那兩行之間不接受 Ctrl-C,
而且改名沒做成的時候登記會收回去,所以中間那一態幾乎不會出現。)
做不到的事(先講清楚,省得你試):
· 不能把 32 位元變成 64 位元。4 GB 是 32 位元指標數得到的上限,
不是哪裡有個設定值可以調大。
· 有光碟保護的原版執行檔,--pool 的樣態找不到;
這支腳本不會、也不打算去碰那一層。
· 解析度表找不到、或找到不只一處,一律停下來,不拿位址去猜。
但會因此停下來的只有 --resolution 那一項;--4gb / --pool / --timestamp
都不看那張表,唯讀體檢也只是少印一段,照樣做得下去。
本站實測:把剛安裝好的英文版原版複製一份,
故意把後四筆特徵破壞掉再跑,--4gb 與 --timestamp 照樣列出預覽、
exit code 0,只有 --resolution 停下來、exit code 2
(--pool 在那顆檔上另有自己的限制,見上一條)。
· --dynasty-year 只改執行檔裡的起始年份,不碰賽程檔,也不用另外去換賽程:
王朝一建立,遊戲就把賽程平移到執行檔設定的年份(本站實測兩組:賽程檔是 2026 年、
執行檔沒改 → 畫面是 2005;賽程檔是 2005 年、執行檔設 2034 或 2100 → 畫面是 2034
或 2100)。其他模式有沒有一樣,本站沒測。已經存在的王朝存檔裡記著的日期,這支
也不會去動。
· --dynasty-year 在有光碟保護的原版執行檔上做不到:兩處寫死的位置都
找不到,會停下來,不去猜。本站量到剛安裝好的英文原版
與中文原版都是這樣。
· --dynasty-year 目前只接受 2005 到 2100(2100 是本站實測過的最大值)。王朝
(Franchise)模式自己的上限是遊戲寫死的 2125 年(遊戲介面寫「120 year
franchise」),所以起始年份離 2125 越近,能打的季數越少(從 2026 起約 99 季)。
· --dynasty-year clock 的限制:只在一台 Windows 機器上實測過(Wine / macOS 沒測);
2100 那一側的夾限只是照程式碼推的、沒有實機測;洞穴依賴三件事,缺一就停下來不猜:
執行檔同時匯入 LoadLibraryA 與 GetProcAddress、甲處前面兩支「模式判斷」的內部結構對得上、
最後一個區段是可執行的而且尾端有夠用的空白。光碟保護的原版找不到甲處,一樣做不到。
已存好的王朝存檔不受影響,但每一次「開新王朝」都以當下的電腦日期為準。
· ⚠️ 老闆模式(Owner Mode)完全不同:它的上限是另一個寫死的 2035 年(遊戲說
「30 Years」),從起始年份 Y 起只能打 2035 - Y 季(依程式碼推算:Y=2026 是 9 季、
Y=2031 是 4 季);起始年份 2035 以後一開始就只剩「退休」(本站實測:起始 2100 的
老闆模式沒有「前進下一日」,直接是「退休」)。第一天「擁有時間」還會顯示
Y - 2005 年(起始 2026 顯示 21 Years),淨值也不一樣(測試者回報:沒改年份是
$2,000,000;本站實測起始 2026 是 $940,671,原因不明)。想完整玩老闆模式(30 年),
請用沒改過年份的執行檔,或改用 --dynasty-year clock(王朝跟著電腦時鐘走、老闆模式維持 2005)。
· 它只改檔案,不會替你設定顯示卡、視窗模式或畫面比例。
改完解析度表畫面還是被拉伸,那是遊戲內部投影比例的事,不在這支的範圍。
· 本站沒有量過遊戲執行時實際吃掉多少記憶體,所以這支不會、也不該
回答你「開了 4 GB 之後球場可以放多大」。
自包含:整支腳本就是這一個檔,不需要安裝任何套件。
授權:MIT(見檔尾完整條款)。本站教學文字另採 CC BY 4.0。
"""
TOOL_DATE = '2026-10-30.4' # 這一版工具的日期(同一天第 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 datetime
import os
import sys
import re
import struct
import shutil
import signal
import argparse
import tempfile
import hashlib
# ── 備份的原子性(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 唯讀資安稽核加)──────────────────────
# 兩件事一起修:
# 1. 所有暫存檔改用 tempfile.mkstemp(dir=目的檔那個資料夾)。名字是隨機的,
# 而且是 O_CREAT|O_EXCL 開出來的,沒有人能事先把那個名字佔成一條
# 指向資料夾外面的符號連結。
# 2. 目的檔與備份檔在寫入前先用 os.path.islink 擋一次。
# ⚠️ 不可以用 os.path.exists():連結指到的東西不存在時它回 False,
# 那條連結就會被當成「這裡沒有檔案」放行。要用 islink / lexists(lstat)。
# ⚠️ 2026-09-06 補上漏掉的那一半:當時 main() 會先把符號連結 realpath 成
# 真檔再往下傳,所以「執行檔本身是連結」那一道其實從來沒亮過 ——
# 跟著連結寫,只是寫到連結指的那個檔而已。現在 main() 不 realpath 了。
# ── Ctrl-C 不可以說謊(2026-09-06 第三輪唯讀稽核加)────────────────────
# 原本只有一個布林值 _TARGET_REPLACED,而且是這樣寫的:
# os.replace(tmp, path)
# _TARGET_REPLACED = True
# 這兩行之間有一個很窄、但真的存在的空窗。Ctrl-C 剛好落在裡面的話,
# 磁碟上的遊戲執行檔**已經換掉了**,而收尾訊息讀到的旗標還是 False,
# 於是印「遊戲執行檔一個位元組都沒有動到」—— 那是假話,而且是最糟的一種假話:
# 玩家會因此不去跑 --restore。
#
# 兩道一起修:
# (a) 把「改名 + 登記」包進 _NoInterrupt。這段期間收到的 SIGINT 先記著,
# 離開這段之後才照常丟出 KeyboardInterrupt,所以登記跟磁碟狀態必定一致。
# (b) 登記改成三態:'idle'(還沒動)/'replacing'(正在換 X)/'replaced'(已換 X)。
# 進 with 之前先寫 'replacing';收尾看到 'replacing' 就要說
# 「出事的時候正在替換 X,請 --restore 或自己拿備份比對」,不可以說沒動到。
# (a) 讓 (b) 幾乎不會被觸發,但 (b) 是保險 —— 例如 os.replace 自己噴 OSError。
# (c) 改名**確定沒做成**的時候(os.replace 自己丟例外、或還沒進 with 就被中斷),
# 收尾把登記收回 'idle'。少了這一步,一次失敗的寫入被中斷會反過來嚇人說
# 「可能已經被換掉了」,而其實一個位元組都沒動。只收 'replacing',
# 已經是 'replaced' 的絕對不收 —— 那才是會害人不去還原的那種謊。
_PROGRESS = {'stage': 'idle', 'target': None, 'action': None} # idle / replacing / replaced
class _NoInterrupt(object):
"""把「os.replace + 登記」包起來:這段期間收到 Ctrl-C 先記著,離開這段之後再照常丟出。
這樣 KeyboardInterrupt 的收尾看到的登記一定跟磁碟上的狀態一致。
這段裡面**只放**改名與登記那兩行,不放任何耗時的工作 ——
不然玩家按了 Ctrl-C 會覺得沒反應。
"""
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 _begin_replace(target, action):
"""要動手改名了。從這一行起,收尾訊息就不可以再說「什麼都沒有動到」。
action 是 'apply'(把改好的內容換上去)或 'restore'(把備份寫回去)。
收尾訊息要靠它才講得出對的那一句:同樣是「已經換好了」,
修改那條路要接還原指令,還原那條路接還原指令就變成叫人再做一次已經做完的事。
"""
_PROGRESS['stage'] = 'replacing'
_PROGRESS['target'] = os.fspath(target)
_PROGRESS['action'] = action
def _mark_replaced():
"""正本已經被換掉了 —— 中斷訊息要改成「已經換好了,要回原樣就 --restore」。"""
_PROGRESS['stage'] = 'replaced'
def _abandon_replace():
"""這一次改名確定沒有發生 —— 把登記收回「還沒動」。
⚠️ 只收 'replacing'。同一個檔案系統上 os.replace 是原子的,它丟例外就代表
一個位元組都沒換過,所以收回去是誠實的;而已經登記成 'replaced' 的
**絕對不收** —— 那會讓收尾訊息謊報「沒動到」,玩家就不會去還原。
"""
if _PROGRESS['stage'] == 'replacing':
_PROGRESS['stage'] = 'idle'
_PROGRESS['target'] = None
_PROGRESS['action'] = None
def _replace_state_note(fallback_path):
"""收尾訊息裡「檔案到底動了沒」那一段,一律照三態登記來寫,不用猜。"""
who = _PROGRESS['target'] or fallback_path
stage = _PROGRESS['stage']
if stage == 'replaced':
if _PROGRESS['action'] == 'restore':
# 還原那條路上「已經換好了」的意思是「備份已經寫回去了」。
# 這裡再印一次 --restore 就是叫人重做一件已經做完的事,所以不印。
return (' ⚠️ 還原那一步在這之前就已經做完了(改名那一步跑完了)——\n'
' 你那顆執行檔現在已經是備份的內容。')
return (' ⚠️ 檔案在這之前就已經換好了(改名那一步跑完了)。\n'
' 要回到原樣就跑這一行:\n %s' % _restore_hint(who))
if stage == 'replacing':
return (' ⚠️ 出事的時候正在替換 %s —— 它可能已經被換掉了。\n'
' 請跑這一行還原,或自己拿備份比對:\n %s'
% (os.path.basename(who), _restore_hint(who)))
return (' 上面印到哪一步就是做到哪一步:沒印出「已寫入」或「已還原」就代表\n'
' 遊戲執行檔還沒被換掉。')
def _reject_symlink(p, what):
"""p 是符號連結就停下來,而且 exit code 不是 0。"""
p = os.fspath(p)
if os.path.islink(p):
raise SystemExit(
'\n 停下來了:%s是一條符號連結(捷徑)——\n'
' %s\n'
' 本工具不跟著捷徑走 —— 跟著走的話,真正被動到的檔可能不是你\n'
' 以為的那一個,而畫面照樣會印成功,那比直接失敗更難發現。\n'
' 請直接對真正的那個檔跑一次,或先把這條捷徑刪掉 / 改名。\n' % (what, p))
def _mkstemp_beside(dst, tag):
"""在 dst 那個資料夾裡開一個猜不到、也不可能被事先佔住的暫存檔。
一定要跟 dst 同一個資料夾:os.replace 只有在同一個檔案系統上才是原子改名,
丟到系統暫存區再搬過來就變成跨檔案系統的複製,原子性就沒了。
"""
dst = os.fspath(dst)
d = os.path.dirname(os.path.abspath(dst)) or '.'
return tempfile.mkstemp(dir=d, prefix='.' + os.path.basename(dst) + '.' + tag + '-')
def _unlink_quiet(p):
"""把暫存檔收掉。收不掉也不要蓋掉正在往上丟的那個例外。"""
try:
os.remove(p)
except OSError:
pass
def _restore_hint(path):
"""「立刻還原」那一行寫成可以直接複製貼上的指令。"""
me = os.path.basename(sys.argv[0]) or 'mvp_modernize.py'
return 'python3 -I %s "%s" --restore' % (me, path)
def _atomic_copy(src, dst):
"""備份要嘛完整、要嘛不存在 —— 中間狀態不會留在 dst 這個名字上。
⚠️ 本站有些腳本用 pathlib.Path 存路徑,有些用字串。
2026-08-29 第一版寫成 dst + '.part',在 Path 上直接 TypeError,
等於所有備份都失敗 —— 而且「半截備份被擋下來」那個測試照樣是綠的。
是陰性對照(先證明正常流程真的會產生備份)抓到的。
做法是兩步:先整份複製到「同一個資料夾裡的一個隨機暫存檔」,
寫完 flush + os.fsync 之後才用 os.replace 把它改名成 dst。
同一個檔案系統上的改名是原子的,所以「dst 這個名字」在任何一個瞬間
要嘛還不存在、要嘛就是一份完整的備份,不會有中間狀態。
途中出任何狀況(含 Ctrl-C)都會把暫存檔收掉再把例外往上丟。
── 2026-09-05 拿掉可以事先算出來的暫存檔名(唯讀資安稽核抓到)──────
原本的暫存檔就叫 dst + '.part',名字任何人都算得出來。只要那個名字
先被放成一條指向資料夾外面的符號連結,shutil.copy2() 就會**跟著連結**
把外面那個檔截成 0 再寫進去;後面的 os.replace 只換掉連結本身,
但外面那個檔在那之前就已經被吃掉了。
"""
src, dst = os.fspath(src), os.fspath(dst)
_reject_symlink(dst, '備份檔')
fd, part = _mkstemp_beside(dst, 'part')
try:
with open(src, 'rb') as fsrc, os.fdopen(fd, 'wb') as fdst:
shutil.copyfileobj(fsrc, fdst)
fdst.flush()
os.fsync(fdst.fileno())
try:
shutil.copystat(src, part) # copy2 的另一半:時間戳。抄不過去不算失敗
except OSError:
pass
if os.path.getsize(part) != os.path.getsize(src):
raise OSError('備份只複製了一部分,不敢把它當成一份完整的備份')
# 這一次改名**沒有**包 _NoInterrupt,是想過才這樣的:換的是備份那個檔名,
# 不是遊戲執行檔。Ctrl-C 剛好落在這裡的話,備份是完整的一份,
# 而「遊戲執行檔一個位元組都沒有動到」照樣是真話 —— 沒有訊息會說謊。
os.replace(part, dst) # os.replace 是原子的
except BaseException:
try:
os.close(fd) # 上面那個 with 已經關掉時會丟 EBADF
except OSError:
pass
_unlink_quiet(part)
raise
def _restore_from_backup(bak, dst):
"""還原之前先擋掉明顯壞掉的備份。
⚠️ 這裡**不能**比對「備份與目標大小相同」—— 本站多數腳本是把資料接到
檔尾來改檔(專案鐵律:封裝檔不可重新打包),改完之後正本本來就比備份大,
那樣比會擋掉每一次合法的還原。
── 2026-08-30 補上三道(上線前資安稽核抓到的真漏洞)────────────────
原本只有「不是 0 bytes」+「BIGF 檔頭宣告長度」兩道。**BIGF 以外全破。**
實測拿「前 1/8 的半截備份」去還原,六支腳本把正本吃掉而且都印成功:
mvp_fix_loc / mvp_menu_text .LOC 416,753 → 52,094
mvp_edit_speed / mvp_ratings
/ mvp_player attrib.dat 840,643 → 105,080
mvp_modernize mvp2005.exe 5 MB 多 → 不到 1 MB
(它還印「複驗:內容與備份相同 ✅」)
最後那個會讓遊戲**完全開不起來**,而站上每一課都寫著「隨時可以 --restore」。
現在檢查五件事:
1. 備份不是 0 bytes
2. BIGF:檔頭第 4-8 個位元組宣告的總長度要等於實際長度
(兩種位元組序都接受;哪些檔是大端、各有幾個,以 reference/bigf.html 量到的為準,這裡不寫會過期的數字)
3. LOCH(語系檔):檔頭指到的 LOCL 要在檔內,而且最後一條字串的位移
也要在檔內 —— 截斷之後那個位移一定會超出去
4. MZ(執行檔):PE 節區表裡 raw offset + raw size 的最大值不得超過檔案長度
5. **通用地板**:非 BIGF 的備份不得小於「要被蓋掉的那個檔」的一半。
非 BIGF 的工具都是原地改(大小幾乎不變),所以這條很安全;
BIGF 走 append 會越改越大,所以刻意**不套**這條,由第 2 道負責。
"""
import struct
bak, dst = os.fspath(bak), os.fspath(dst)
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 個位元組寫著「我應該有多長」,
# 跟實際長度一比就知道有沒有被截斷。本站實測本機 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 道:LOCH 語系檔。位移 16 起 4 個位元組(小端序)指向 LOCL 區,
# LOCL 區再往後 12 個位元組是字串條數,接著是每條 4 個位元組的位移表。
# 檔案被截斷時,最後一條字串的位移一定會指到檔案結尾外面。
if len(head) >= 4 and head[:4] == b'LOCH':
try:
d = open(bak, 'rb').read()
L = struct.unpack('<I', d[16:20])[0]
if L + 16 > n or d[L:L + 4] != b'LOCL':
_stop('語系檔的字串區(LOCL)應該在位移 %d,那裡不是 LOCL。' % L)
lcnt = struct.unpack('<I', d[L + 12:L + 16])[0]
if lcnt <= 0 or L + 16 + lcnt * 4 > n:
_stop('語系檔的位移表被截斷了(宣告 %d 條)。' % lcnt)
last = struct.unpack('<I', d[L + 16 + (lcnt - 1) * 4:L + 20 + (lcnt - 1) * 4])[0]
if L + last >= n:
_stop('語系檔最後一條字串在位移 %d,超出檔案結尾(%d bytes)。'
% (L + last, n))
except SystemExit:
raise
except (struct.error, IndexError):
_stop('讀不出語系檔的結構,它壞了。')
# 第 4 道:MZ 執行檔(這支腳本自己的備份就是走這一道)。
# 0x3C 起 4 個位元組指向 PE 檔頭;PE+6 是節區數、PE+20 是 Optional Header
# 的長度,節區表就接在 Optional Header 後面,每個節區 40 個位元組,
# 其中 +16 是資料長度、+20 是資料在檔案裡的位移(都是小端序)。
# 把每個節區的「位移 + 長度」取最大值,那就是這個檔至少該有多長。
if len(head) >= 2 and head[:2] == b'MZ':
try:
d = open(bak, 'rb').read()
pe = struct.unpack('<I', d[0x3C:0x40])[0]
if pe + 24 > n or d[pe:pe + 4] != b'PE\x00\x00':
_stop('執行檔的 PE 檔頭不在它該在的地方,檔案不完整。')
nsec = struct.unpack('<H', d[pe + 6:pe + 8])[0]
optsz = struct.unpack('<H', d[pe + 20:pe + 22])[0]
sec = pe + 24 + optsz
end = 0
for i in range(nsec):
o = sec + i * 40
if o + 40 > n:
_stop('執行檔的節區表被截斷了(宣告 %d 個節區)。' % nsec)
raw_sz, raw_off = struct.unpack('<II', d[o + 16:o + 24])
end = max(end, raw_off + raw_sz)
if end > n:
_stop('執行檔的節區指到 %d bytes,實際只有 %d bytes。' % (end, n))
except SystemExit:
raise
except (struct.error, IndexError):
_stop('讀不出執行檔的結構,它壞了。')
# 通用地板 —— 非 BIGF 走到這裡
# 前面那幾道都是格式專屬的,認不出格式的備份就只剩這一道。
# 用「不到一半」當門檻而不是「大小要相同」:原地改的工具改完大小幾乎不變,
# 但也可能差幾個位元組,抓太緊會擋掉每一次正常的還原。
if os.path.exists(dst):
live = os.path.getsize(dst)
if live > 0 and n * 2 < live:
_stop('備份只有 %d bytes,而要被蓋掉的那個檔有 %d bytes ——'
'差太多了(不到一半)。' % (n, live))
return _do_copy(bak, dst)
def _do_copy(bak, dst):
"""真正覆蓋的那一步,單獨拆成一個函式,是為了讓上面每一道守門都寫成
「檢查不過就 raise,檢查過才走到這裡」,不會有哪一條路徑漏掉檢查。
── 2026-09-05 改成原子還原(唯讀資安稽核抓到的 🔴)────────────────
原本是 shutil.copy2(bak, dst)。copy2 會**先把 dst 截成 0 bytes**,
再一段一段把備份寫進去。中途按 Ctrl-C、磁碟滿、外接碟被拔掉,
玩家的遊戲執行檔就停在 0 或半截 —— 而這一支正是「出事時用來救命」的那條路,
救命的路自己把檔吃掉是最不能接受的一種。
(前面那幾道守門擋的是「備份本身壞掉」,擋不到「複製到一半死掉」。)
現在的順序:
1. 在 dst 那個資料夾裡開一個隨機暫存檔(mkstemp,而且先擋符號連結)
2. 把備份的內容整份寫進去,flush + os.fsync 真的落地
3. 把 dst 原本的權限抄到暫存檔(不然執行檔會掉可執行位元)
4. 再讀回來,跟備份比 sha256,不一樣就當場失敗
5. 全部過了才 os.replace(暫存檔, dst) —— 這一步是原子的
任何一步失敗都把暫存檔收掉,dst 一個位元組都沒被碰過。
"""
bak, dst = os.fspath(bak), os.fspath(dst)
_reject_symlink(bak, '備份檔')
_reject_symlink(dst, '要還原的目標檔')
with open(bak, 'rb') as f:
want = f.read()
fd, tmp = _mkstemp_beside(dst, 'restore')
try:
with os.fdopen(fd, 'wb') as f:
f.write(want)
f.flush()
os.fsync(f.fileno())
try:
shutil.copymode(dst, tmp) # dst 已經被刪掉就沒有權限可抄,跳過
except OSError:
pass
with open(tmp, 'rb') as f:
got = f.read()
if hashlib.sha256(got).hexdigest() != hashlib.sha256(want).hexdigest():
raise OSError('寫出來的暫存檔跟備份對不起來,沒有換上去')
_begin_replace(dst, 'restore')
with _NoInterrupt(): # 改名 + 登記中間不接受 Ctrl-C(見 _NoInterrupt)
os.replace(tmp, dst)
_mark_replaced()
except BaseException:
try:
os.close(fd) # 上面那個 with 已經關掉時會丟 EBADF
except OSError:
pass
_unlink_quiet(tmp)
# 走到這裡如果登記還停在「正在換」,就代表 os.replace 沒做成(它是原子的,
# 丟例外就是沒換);已經是「已換」的不會被收回去。
_abandon_replace()
raise
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except Exception:
pass
class DataError(Exception):
"""檔案內容跟預期不符。一律當成「停下來問人」,不要猜。"""
# BACKUP_SUFFIX:備份的檔名就是「原檔名 + 這條尾巴」,所以備份一定跟遊戲執行檔
# 躺在同一個資料夾,不會被丟到暫存區然後被系統清掉。每一支本站腳本的尾巴都不同,
# 這樣四支工具的備份放在一起也不會互相蓋掉。
# LARGE_ADDRESS_AWARE:PE 檔頭 Characteristics 那個 16 位元欄位裡的一個位元。
# 整個 --4gb 做的事就是把這一個位元從 0 變成 1,其他什麼都沒動。
# MACHINE_I386:COFF 檔頭的 Machine 欄位,0x014C 代表 32 位元 x86。
# 這支只把它印出來給你確認,不會去改(改了也不會讓程式變成 64 位元)。
BACKUP_SUFFIX = '.modernizebak'
LARGE_ADDRESS_AWARE = 0x0020 # PE Characteristics 的旗標位元
MACHINE_I386 = 0x014C
# 遊戲內建的解析度表:8 筆,四種尺寸各有 16 與 32 位元色深:
# 640x480 / 800x600 / 1024x768 / 1280x1024,每種各有 16 與 32 位元色深。
#
# ⚠️ 特徵故意用**後四筆**(1024x768 與 1280x1024),不是開頭那兩筆。
# 原因:改解析度時換掉的是最前面那組 640x480,
# 如果拿它當特徵,改完之後腳本就再也找不到自己改過的表了
# (第一版真的是這樣,執行一次就看到了)。後四筆不會被這支腳本動到。
RECORD = 16
TABLE_ROWS = 8
TAIL_SIGNATURE = (struct.pack('<IIII', 1024, 768, 16, 0) +
struct.pack('<IIII', 1024, 768, 32, 0) +
struct.pack('<IIII', 1280, 1024, 16, 0) +
struct.pack('<IIII', 1280, 1024, 32, 0))
TAIL_OFFSET = 4 * RECORD # 後四筆從表的第 5 筆開始
# 記憶體池:遊戲開機時 malloc 一大塊自己切,全部資源(球場、字型、.big)都住在裡面。
# 原廠 64 MB 是照 2005 年主機的額度切的。中文字型要在執行時展開成貼圖,
# 光字型就吃掉 13.50 MB,配上大球場就會把池子撐爆 → 配置失敗 → 對 NULL 寫入 → 當機。
# 樣態:push imm32 / call rel32 / pop ecx / ret,imm 是 1 MB 的倍數且落在 64..1024 MB。
POOL_RE = re.compile(rb'\x68(....)\xe8....\x59\xc3', re.S)
POOL_MIN = 64 * 1024 * 1024
POOL_MAX = 1024 * 1024 * 1024
# 王朝起始年份(--dynasty-year,2026-10-29 加)。程式碼裡有兩處把 2005 寫死:
# 甲:cmp al,1 / jne +0C / push <年> / mov ecx,esi / call <設年函式>
# (某個模式判斷成立就把日曆年份設成 <年>,王朝建立時走這一處;
# 前面的 cmp/jne 與後面的 mov ecx,esi 是樣態的一部分,call 的相對位址不比對,
# 因為不同版本的位址不一樣)
# 乙:mov byte [esp],3 / mov byte [esp+1],26 / mov word [esp+2],<年> / mov eax,[esp] / push eax / call
# (在堆疊上組一個 {3 月、26 日、<年>} 的日期;不是王朝的模式拿它當算年齡的基準日)
# 這兩處就是社群手改執行檔改年份時動的那兩處。
#
# YEAR_MAX 為什麼是 2100:這是本站在 Windows 實機驗過的最大起始年份(2026-10-29:起始 2034
# 打完一季進 2035;起始 2100 日期與星期都對、整季打完進 2101)。王朝(Franchise)模式自己的
# 上限是寫死的 2125 年(0x84D,遊戲介面寫「120 year franchise」),所以 2101 到 2123
# 理論上可以、但沒測,不開放。
# ⚠️ 這裡以前寫「2034,因為每季往一個 30 格陣列記一筆(索引 = 年份 - 2005)」,對王朝是錯的:
# 那個陣列只有老闆模式(Owner Mode)會寫,而老闆模式的上限是另一個寫死的 2035 年;
# 王朝不寫那個陣列。2026-10-29 靜態分析發現、同日實機證實。
# 老闆模式因此不會跟著起始年份變長:從 Y 起只能打 2035 - Y 季,見 OWNER_END_YEAR。
# YEAR_MIN 為什麼是 2005:小於 2005 時「年份減 2005」是負的,本站只看過其中幾處
# 會安靜略過,沒有逐一確認,所以不開放。
YEAR_MIN = 2005
YEAR_MAX = 2100
FRANCHISE_END_YEAR = 2125 # 王朝(Franchise)的上限年份:絕對年份,不是從起始年算起
OWNER_END_YEAR = 2035 # 老闆模式(Owner Mode)的上限年份:到了這一年選單只剩「退休」
YEAR_SETYEAR_RE = re.compile(rb'\x3c\x01\x75\x0c\x68(..)\x00\x00\x8b\xce\xe8....', re.S)
YEAR_REFDATE_RE = re.compile(
rb'\xc6\x04\x24\x03\xc6\x44\x24\x01\x1a\x66\xc7\x44\x24\x02(..)\x8b\x04\x24\x50\xe8', re.S)
YEAR_CLOCK = 'clock' # --dynasty-year clock:開新王朝時讀電腦的日期,不寫死
# 時鐘版的甲處:`cmp al,1 / jne +0C / call <洞穴> / mov ecx,esi / call SetYear`
# (原版同一個位置是 `push <年份>`,見 YEAR_SETYEAR_RE;兩個樣態一次只會有一個)
CLOCK_SETYEAR_RE = re.compile(rb'\x3c\x01\x75\x0c\xe8(....)\x8b\xce\xe8....', re.S)
CLOCK_CAVE_LEN = 133 # 洞穴實際寫進去的位元組數
CLOCK_CAVE_ROOM = 144 # 洞穴那一段要先確認全是 0 的長度(多留幾格對齊的空隙)
# ─────────────────────────────────────────────────────────
# PE 檔頭
# ─────────────────────────────────────────────────────────
def pe_offsets(data):
"""回傳 (PE 起點, Characteristics 位置, CheckSum 位置)。不是 PE 就丟錯。
這三個位置全部用算的,不用搜,因為 PE 檔的排法是固定的:
· 位移 0x3C 起 4 個位元組(32 位元小端序)寫著 PE 檔頭在哪裡。
· 那個位置起 4 個位元組是 P、E、0x00、0x00,接著 20 個位元組的 COFF 檔頭。
· 下面的 +N 一律從「PE 起點」算,不是從 COFF 檔頭起點算,兩者差 4 個位元組。
· Characteristics 在 PE 起點 +22,佔 2 個位元組,--4gb 動的就是它。
它在 COFF 檔頭裡自己的位移是 18,而 COFF 檔頭一共只有 20 個位元組,
本來就裝不下「+22」;照字面推成「COFF 起點 +22」會算到 PE 起點 +26,
那裡已經是 Optional Header 的連結器版本,寫下去旗標不會生效,還會把
版本號蓋掉。
· Optional Header 從 PE 起點 +24 開始,剛好接在 COFF 檔頭後面;
CheckSum 在 Optional Header 的 +64。
· PE 起點不是固定值,所以這三個位置一定要算、不能記。本站測試機上量過的
21 個 mvp2005.exe 裡,英文版那 13 個
PE 起點都在 0x128,Characteristics 落在 0x13E、CheckSum 落在
0x180;中文版那 8 個 PE 起點在 0x130,
Characteristics 落在 0x146、CheckSum 落在 0x188。
這裡故意不接受「看起來差不多像 PE」的檔案:MZ 不對、PE 簽章不在該在的地方、
或是檔案短到連 PE 檔頭都讀不完,一律當成「這不是我認得的檔」停下來,
不要在看不懂的檔案上算位址。
"""
if len(data) < 0x40 or data[:2] != b'MZ':
raise DataError('這個檔的開頭不是 MZ,不是 Windows 執行檔。')
pe = struct.unpack_from('<I', data, 0x3C)[0]
if pe + 24 > len(data) or data[pe:pe + 4] != b'PE\x00\x00':
raise DataError('找不到 PE 檔頭,這個檔可能已損毀。')
# 這一行要驗到 CheckSum 的最後一個位元組(PE 起點 +88 +4 = +92,也就是
# +24 +68)。只驗到 +24 的話,一顆「開頭是 MZ、PE 簽章也在、但檔案被截斷」
# 的執行檔會過這一關,然後在 describe() 讀 CheckSum 時噴 Python 追蹤訊息。
if pe + 24 + 68 > len(data):
raise DataError('PE 檔頭在 0x%X,但整個檔只有 %d 位元組 —— 讀不完檔頭,'
'這個檔是半截的。' % (pe, len(data)))
return pe, pe + 22, pe + 24 + 64 # Optional header 起點 +64 = CheckSum
def pe_checksum(data, ck_off):
"""PE 檔頭的 CheckSum:16 位元加總(進位摺回)+ 檔案大小。
計算時把 CheckSum 欄位自己當成 0。
⚠️ 本站拿一個已經套過修正的執行檔驗過這個算法:算出來跟它檔頭裡寫的
完全相同。另外兩份剛安裝好的原版檔頭寫的是 0 —— EA 沒有填,
而 Windows 對一般程式本來就不檢查這一欄。所以這裡算對是為了工整,
不是因為不算會壞。
算法本身:整個檔案每 2 個位元組當成一個小端序的 16 位元數字加起來,
每加一次就把進位摺回低 16 位元(這樣總和永遠不會溢位);跳過 CheckSum
欄位自己那 4 個位元組(當成 0);檔案長度是奇數時最後那一個位元組單獨加;
最後再加上檔案總長度。
"""
s = 0
n = len(data)
for i in range(0, n - (n & 1), 2):
if ck_off <= i < ck_off + 4:
continue
s += data[i] | (data[i + 1] << 8)
s = (s & 0xFFFF) + (s >> 16)
if n & 1:
s += data[-1]
s = (s & 0xFFFF) + (s >> 16)
s = (s & 0xFFFF) + (s >> 16)
return (s + n) & 0xFFFFFFFF
def find_table(data):
"""用內容找解析度表。回傳起點位置。找不到就講清楚,不猜。
找法:把後四筆(1024x768 與 1280x1024,各有 16 與 32 位元色深)接成
64 個位元組的特徵去搜,搜到之後往回退 TAIL_OFFSET 就是整張表的起點。
每一筆固定 16 個位元組、四個 32 位元小端序數字,所以特徵是可以直接算出來的。
找到不只一處也停下來,不用「第一個」湊答案。這種檔案上猜錯位址,
寫下去就是把別的東西改壞。
"""
hits = []
start = 0
while True:
i = data.find(TAIL_SIGNATURE, start)
if i < 0:
break
hits.append(i - TAIL_OFFSET)
start = i + 1
if not hits:
raise DataError('在這個執行檔裡找不到解析度表。\n'
' 本站是用後四筆(1024x768 與 1280x1024,各有 16 與 32 位元色深)\n'
' 連在一起當特徵找的。找不到代表這個版本的表長得不一樣 ——\n'
' 腳本不敢亂猜位址。')
if len(hits) > 1:
raise DataError('找到 %d 個看起來像解析度表的地方(%s)。\n'
' 不確定是哪一個,所以停下來。'
% (len(hits), '、'.join('0x%X' % h for h in hits)))
return hits[0]
def find_pool(data):
"""回傳 [(imm32 的檔案位移, 目前的位元組數), ...]。
通常會找到兩處。本站在測試機那顆 mvp2005.exe 上量到:
push 那個位元組在 0x3736F0 與 0x3CDD08(本函式回傳的是它們的立即數位移
0x3736F1 與 0x3CDD09;64 MB 改成 128 MB 真正動到的是最高位那一格,
也就是 0x3736F4 與 0x3CDD0C)。兩處的 push 五個位元組完全相同,
後面 call 的相對位移不同,但算出來的目標是同一個位址 0x3CDCCC。
把整個 .text 的 E8 相對呼叫掃過一遍,0x3736F0 有 1 個直接呼叫者、
0x3CDD08 是 0 個,兩個位址也都沒有以 4 位元組常數出現在檔案的任何地方。
這是純位元組比對的結果,沒有反組譯去確認 0x3CDD08 有沒有間接呼叫
(例如透過表格或算出來的位址),所以只講到「1 比 0」為止,
不能斷定它就是編譯器留下的死碼。兩處一起改,反正它們本來就是同一個值。
找法是找那一段機器碼的形狀(POOL_RE):push 一個 32 位元立即數、
call 一個相對位址、pop ecx、ret。m.start() 指到 push 那個位元組,
+1 才是立即數本身的位移,所以回傳的是 m.start() + 1。
"""
out = []
for m in POOL_RE.finditer(data):
v = struct.unpack('<I', m.group(1))[0]
# 光靠指令形狀會撞到一堆不相干的地方,所以再要求這個數字
# 「剛好是 1 MB 的整數倍,而且落在 64 MB 到 1024 MB 之間」。
# 記憶體池的大小本來就是照 MB 開的,一般常數不會剛好長這樣。
if v % (1024 * 1024) == 0 and POOL_MIN <= v <= POOL_MAX:
out.append((m.start() + 1, v))
return out
def find_year_sites(data):
"""回傳 (甲處清單, 乙處清單),每一項是 (年份那 2 個位元組的檔案位移, 目前的年份)。
找法是找那一段機器碼的形狀(YEAR_SETYEAR_RE 與 YEAR_REFDATE_RE),
年份本身用括號抓出來。年份是 16 位元小端序;甲處的 push 是 32 位元立即數,
樣態要求它後面兩個位元組是 00 00,所以只需要動前面 2 個。
各自要剛好一個,呼叫端負責檢查(0 個代表這顆檔有光碟保護或長得不一樣,
2 個以上代表不確定是哪一個,兩種都停下來,不猜)。
"""
a = [(m.start(1), struct.unpack('<H', m.group(1))[0])
for m in YEAR_SETYEAR_RE.finditer(data)]
b = [(m.start(1), struct.unpack('<H', m.group(1))[0])
for m in YEAR_REFDATE_RE.finditer(data)]
return a, b
# ─────────────────────────────────────────────────────────
# 王朝起始年份「跟著電腦時鐘走」(--dynasty-year clock,2026-10-29)
#
# 做法:把甲處那條 5 個位元組的 `push <年份>` 換成 `call <洞穴>`。洞穴是一小段(133 位元組)
# 機器碼,放在「檔案最後一個區段」的空白尾端 —— 那個區段本來就可執行、檔案裡就有、
# 所有這一族的執行檔都一樣,所以不用加區段、不用改檔案大小。洞穴做的事:
# 老闆模式(模式 13)→ push 2005,跟原版一模一樣
# 其他(王朝,模式 12)→ 用 LoadLibraryA + GetProcAddress 取得 GetLocalTime(執行檔本來
# 就匯入這兩個,不必新增匯入),問電腦現在是幾年 → 夾在 2005 到 2100 → push 那個年份
# GetProcAddress 找不到 → push 2005(退回原版的行為)
# 然後 jmp 回 `mov ecx,esi`,接著跑原本的流程。
# 乙處(非王朝模式算年齡的 3 月 26 日基準日)不動,維持 2005:那些模式的日曆年是賽程檔的年份。
#
# 每一個位址都從執行檔自己的內容推出來(不記位址),每一個前提都先確認;
# 任何一個不成立就停下來,不猜。
# ─────────────────────────────────────────────────────────
def _must(cond, msg):
"""前提不成立就丟 DataError。不用 assert:python -O 會把 assert 整段拿掉。"""
if not cond:
raise DataError(msg)
def _pe_map(data):
"""回傳 (ImageBase, [(虛擬大小, RVA, 檔案內大小, 檔案位移, 旗標), ...], 匯入表 RVA)。
只認 32 位元的 PE(Optional Header 魔數 0x10B)。節區表、資料目錄只要有一格讀不完,
就當成這顆檔不是我認得的格式。
"""
pe, _ch, _ck = pe_offsets(data)
_must(struct.unpack_from('<H', data, pe + 24)[0] == 0x10B,
'不是 32 位元的 PE(Optional Header 魔數不是 0x10B)。')
n = struct.unpack_from('<H', data, pe + 6)[0]
osz = struct.unpack_from('<H', data, pe + 20)[0]
_must(112 <= osz and 1 <= n <= 96, '節區數或 Optional Header 長度不合理(%d 個節區、%d 位元組)。' % (n, osz))
first = pe + 24 + osz
_must(first + 40 * n <= len(data), '節區表被截斷。')
base = struct.unpack_from('<I', data, pe + 24 + 28)[0]
secs = []
for i in range(n):
o = first + 40 * i
vs, va, rs, rp = struct.unpack_from('<IIII', data, o + 8)
ch = struct.unpack_from('<I', data, o + 36)[0]
secs.append((vs, va, rs, rp, ch))
imp = struct.unpack_from('<I', data, pe + 24 + 96 + 8)[0]
return base, secs, imp
def _sec_extent(s):
"""這個節區「載入後有而且檔案裡也有」的長度。"""
vs, _va, rs, _rp, _ch = s
return min(vs, rs) if vs else rs
def _rva_off(secs, rva, data_len):
for s in secs:
if s[1] <= rva < s[1] + _sec_extent(s) and s[3] + (rva - s[1]) < data_len:
return s[3] + (rva - s[1])
raise DataError('位址 RVA 0x%X 不在任何節區裡(或檔案裡沒有這一段)。' % (rva & 0xFFFFFFFF))
def _off_rva(secs, off):
for s in secs:
if s[3] <= off < s[3] + _sec_extent(s):
return s[1] + (off - s[3])
raise DataError('檔案位移 0x%X 不在任何節區裡。' % off)
def _cstr(data, off):
"""從 off 起讀到 \0 為止的位元組(最長 256)。"""
end = data.find(b'\0', off, off + 256)
_must(end >= 0, '位移 0x%X 起 256 個位元組內沒有字串結尾。' % off)
return bytes(data[off:end])
def _clock_iat(data, base, secs, imp_rva):
"""回傳 (LoadLibraryA 的 IAT 位址, GetProcAddress 的 IAT 位址),都是虛擬位址。
洞穴用 `call [IAT 位址]` 呼叫它們,所以一定要是執行檔「本來就匯入」的:
kernel32 沒有同時匯入兩個就停下來,不去新增匯入。
"""
_must(imp_rva != 0, '這顆檔沒有匯入表。')
n = len(data)
want = {b'LoadLibraryA': 'load', b'GetProcAddress': 'getproc'}
got = {}
try:
o = _rva_off(secs, imp_rva, n)
for _ in range(4096):
oft, _ts, _fc, name, ft = struct.unpack_from('<IIIII', data, o)
if not name:
break
if _cstr(data, _rva_off(secs, name, n)).lower() == b'kernel32.dll':
t = _rva_off(secs, oft or ft, n)
for k in range(65536):
v = struct.unpack_from('<I', data, t + 4 * k)[0]
if not v:
break
if not v & 0x80000000:
fn = _cstr(data, _rva_off(secs, v, n) + 2)
if fn in want:
_must(want[fn] not in got, 'kernel32 的 %s 匯入了不只一次。' % fn.decode())
got[want[fn]] = base + ft + 4 * k
o += 20
except struct.error:
raise DataError('匯入表讀到一半就沒了(檔案被截斷或格式不對)。')
_must(set(got) == {'load', 'getproc'},
'kernel32 沒有同時匯入 LoadLibraryA 與 GetProcAddress(找到:%s)。' % (sorted(got) or '都沒有'))
return got['load'], got['getproc']
def _clock_core(data, site):
"""從甲處(`cmp al,1` 那個位元組的位移 site)算出洞穴要接的所有位址。
甲處前面的樣子是固定的:
call 單例 / mov ecx,eax / call 「模式是 12 或 13」 / cmp al,1 / jne
「模式是 13」(老闆模式)那支判斷和「模式是 12 或 13」是同一族,就在它前面 0x40 的地方;
兩支各自的內部結構也先對一遍,對不上就停下來,不拿一個沒確認過的位址來呼叫。
回傳 dict;每個前提不成立就丟 DataError。
"""
n = len(data)
_must(site >= 12 and site + 16 <= n, '甲處離檔案邊界太近。')
base, secs, imp = _pe_map(data)
push_off = site + 4
push_va = base + _off_rva(secs, push_off)
_must(data[site - 12] == 0xE8 and bytes(data[site - 7:site - 5]) in (b'\x89\xc1', b'\x8b\xc8')
and data[site - 5] == 0xE8,
'甲處前面不是預期的「call 單例 / mov ecx,eax / call 判斷」。')
def rel(o):
return struct.unpack_from('<i', data, o)[0]
singleton = base + _off_rva(secs, site - 12) + 5 + rel(site - 11)
pred1213 = base + _off_rva(secs, site - 5) + 5 + rel(site - 4)
pred13 = pred1213 - 0x40
p13 = _rva_off(secs, pred13 - base, n)
p1213 = _rva_off(secs, pred1213 - base, n)
_rva_off(secs, singleton - base, n) # 單例的位址也要真的在檔案裡
body = bytes(data[p13:p13 + 0x38])
_must(len(body) == 0x38 and body[0] == 0xE8 and body[5:12] == b'\x80\xb8\xb2\x00\x00\x00\x01'
and body[0x1f:0x25] == b'\x83\xf8\x0d\x0f\x94\xc0' and body[0x31:0x37] == b'\x83\xf8\x0d\x0f\x94\xc0',
'0x%X 那支不像「模式是 13」的判斷(結構對不上),不敢拿來用。' % pred13)
b2 = bytes(data[p1213:p1213 + 0x22])
_must(len(b2) == 0x22 and b2[0] == 0xE8 and b2[5:12] == b'\x80\xb8\xb2\x00\x00\x00\x01'
and b2[0x1f:0x22] == b'\x83\xf8\x0c',
'0x%X 那支不像「模式是 12 或 13」的判斷(結構對不上),不敢拿來用。' % pred1213)
load, getproc = _clock_iat(data, base, secs, imp)
return {'base': base, 'secs': secs, 'site': site, 'push_off': push_off, 'push_va': push_va,
'back_va': push_va + 5, 'singleton': singleton, 'pred13': pred13,
'load': load, 'getproc': getproc}
def clock_cave_code(cave_va, singleton, pred13, back_va, load, getproc):
"""組出洞穴那 133 個位元組。位址全是參數,所以同一份程式能替每一顆執行檔各組一份。"""
code = bytearray()
def emit(*parts):
for p in parts:
code.extend(p if isinstance(p, (bytes, bytearray)) else bytes([p]))
def here():
return cave_va + len(code)
def call_rel(target):
emit(0xE8, struct.pack('<i', target - (here() + 5)))
def jmp_rel(target):
emit(0xE9, struct.pack('<i', target - (here() + 5)))
str1, str2 = b'kernel32.dll\0', b'GetLocalTime\0'
s1_at, s2_at = 107, 120
emit(0x58) # pop eax 丟掉 call 壓進來的返回位址
call_rel(singleton) # call 單例
emit(0x89, 0xC1) # mov ecx,eax
call_rel(pred13) # call 「模式是 13」
emit(0x3C, 0x01) # cmp al,1
emit(0x75, 0x0A) # jne 非老闆模式
emit(0x68, struct.pack('<I', YEAR_MIN)) # push 2005 老闆模式:跟原版一模一樣
jmp_rel(back_va)
_must(len(code) == 27, '洞穴組裝出錯:老闆模式那一段長度不對。')
emit(0x68, struct.pack('<I', cave_va + s1_at)) # push "kernel32.dll"
emit(0xFF, 0x15, struct.pack('<I', load)) # call [LoadLibraryA]
emit(0x68, struct.pack('<I', cave_va + s2_at)) # push "GetLocalTime"
emit(0x50) # push eax hModule
emit(0xFF, 0x15, struct.pack('<I', getproc)) # call [GetProcAddress]
emit(0x85, 0xC0) # test eax,eax
_must(len(code) == 52, '洞穴組裝出錯:找 GetLocalTime 那一段長度不對。')
emit(0x74, 0x2B) # jz 退回原版
emit(0x83, 0xEC, 0x10) # sub esp,16 SYSTEMTIME
emit(0x54) # push esp
emit(0xFF, 0xD0) # call eax GetLocalTime(&st),stdcall 自己清參數
emit(0x0F, 0xB7, 0x04, 0x24) # movzx eax, word [esp] wYear
emit(0x83, 0xC4, 0x10) # add esp,16
emit(0x3D, struct.pack('<I', YEAR_MIN)) # cmp eax,2005
emit(0x7D, 0x05) # jge +5
emit(0xB8, struct.pack('<I', YEAR_MIN)) # mov eax,2005 時鐘亂掉也不會設出負的年份
emit(0x3D, struct.pack('<I', YEAR_MAX)) # cmp eax,2100
emit(0x7E, 0x05) # jle +5
emit(0xB8, struct.pack('<I', YEAR_MAX)) # mov eax,2100 沒測過的範圍不開放
emit(0x50) # push eax SetYear 的參數
jmp_rel(back_va)
_must(len(code) == 97, '洞穴組裝出錯:取年份那一段長度不對。')
emit(0x68, struct.pack('<I', YEAR_MIN)) # 退回原版:push 2005
jmp_rel(back_va)
_must(len(code) == s1_at, '洞穴組裝出錯:字串該放的位置不對。')
emit(str1)
_must(len(code) == s2_at, '洞穴組裝出錯:第二個字串該放的位置不對。')
emit(str2)
_must(len(code) == CLOCK_CAVE_LEN, '洞穴組裝出錯:總長度是 %d,不是 %d。' % (len(code), CLOCK_CAVE_LEN))
return bytes(code)
def clock_plan(data):
"""還沒打過時鐘補丁的檔,要怎麼改:回傳施工圖 dict;辦不到就丟 DataError(說明為什麼)。
甲處要剛好一個「push 年份」(年份是多少都行 —— 2005,或這支腳本以前改成的 2026,
都可以換成時鐘)。洞穴放在最後一個節區「已經有內容的部分」之後,空一格再對齊 16:
那個節區要可執行、檔案內大小不超過虛擬大小、洞穴那一段(CLOCK_CAVE_ROOM 個位元組)
要全是 0。
"""
ya, _yb = find_year_sites(data)
_must(len(ya) == 1, '甲處(設年份那處)找到 %d 個,要剛好 1 個才能改成時鐘版。' % len(ya))
core = _clock_core(data, ya[0][0] - 5)
vs, va, rs, rp, ch = core['secs'][-1]
_must(ch & 0x20000000, '最後一個節區不可執行,洞穴放不進去。')
_must(rs <= vs and rp + rs <= len(data), '最後一個節區的大小不合(檔案內 %d、虛擬 %d)。' % (rs, vs))
used = len(bytes(data[rp:rp + rs]).rstrip(b'\0'))
cave_rel = (used + 15 + 16) & ~15
_must(cave_rel + CLOCK_CAVE_ROOM <= rs,
'最後一個節區放不下洞穴(已用 %d、節區 %d 位元組)。' % (used, rs))
cave_off = rp + cave_rel
_must(not any(data[cave_off:cave_off + CLOCK_CAVE_ROOM]), '洞穴位置不是全 0,不敢蓋。')
core['cave_off'] = cave_off
core['cave_va'] = core['base'] + va + cave_rel
core['code'] = clock_cave_code(core['cave_va'], core['singleton'], core['pred13'],
core['back_va'], core['load'], core['getproc'])
return core
def clock_state(data):
"""甲處現在是不是時鐘版。回傳 (狀態, 資訊):
(None, None) 甲處不是「call」的樣子(原版的 push 年份,或根本找不到)
('clock', 資訊) 甲處是 call,而且洞穴逐位元組等於本工具會替這顆檔組出來的那一份
('foreign', {'why'}) 甲處是 call、或樣態出現的次數不對,但洞穴不是本工具做的 —— 不碰
"""
ms = list(CLOCK_SETYEAR_RE.finditer(data))
if not ms:
return None, None
ya, _yb = find_year_sites(data)
if len(ms) != 1 or ya:
return 'foreign', {'why': '甲處樣態出現 %d 個「呼叫」與 %d 個「push 年份」,不確定哪一個是真的'
% (len(ms), len(ya))}
try:
core = _clock_core(data, ms[0].start())
cave_va = core['push_va'] + 5 + struct.unpack('<i', ms[0].group(1))[0]
cave_off = _rva_off(core['secs'], cave_va - core['base'], len(data))
vs, va, rs, rp, ch = core['secs'][-1]
_must(rp <= cave_off and cave_off + CLOCK_CAVE_LEN <= rp + rs and ch & 0x20000000,
'呼叫的目標 0x%X 不在最後一個可執行節區裡。' % cave_va)
want = clock_cave_code(cave_va, core['singleton'], core['pred13'], core['back_va'],
core['load'], core['getproc'])
_must(bytes(data[cave_off:cave_off + CLOCK_CAVE_LEN]) == want,
'呼叫的目標 0x%X 那一段不是本工具會產生的內容。' % cave_va)
except (DataError, struct.error) as e:
return 'foreign', {'why': str(e)}
core['cave_off'] = cave_off
core['cave_va'] = cave_va
return 'clock', core
def clock_regions(data):
"""時鐘版會動到的格子:甲處 5 個位元組與洞穴 133 個位元組。算不出來(不是這一族)就回傳 []。
給 edit_regions 用。「還沒打時鐘」與「已經打了時鐘」兩種狀態算出來的是同一組格子:
洞穴的位置在未打補丁的檔上是算的(最後一個節區的空白尾端),在打過的檔上是從甲處的
call 目標讀的。
"""
st, info = clock_state(data)
try:
if st is None:
info = clock_plan(data)
elif st != 'clock':
return []
except (DataError, struct.error):
return []
return [(info['push_off'], 5), (info['cave_off'], CLOCK_CAVE_LEN)]
def clock_install(out, plan):
"""在記憶體裡的複本 out 上套時鐘版:洞穴寫進去,甲處 5 個位元組換成 call 洞穴。"""
out[plan['cave_off']:plan['cave_off'] + CLOCK_CAVE_LEN] = plan['code']
out[plan['push_off']:plan['push_off'] + 5] = (
b'\xE8' + struct.pack('<i', plan['cave_va'] - plan['back_va']))
def clock_remove(out, info, year):
"""把時鐘版拆回「寫死 year」:洞穴清成 0,甲處 5 個位元組換回 push 年份。
結果和直接從沒打過時鐘的檔改成這個年份逐位元組相同(甲處的 push 立即數是
32 位元,年份在前兩個位元組、後兩個位元組是 00 00)。
"""
out[info['cave_off']:info['cave_off'] + CLOCK_CAVE_LEN] = bytes(CLOCK_CAVE_LEN)
out[info['push_off']:info['push_off'] + 5] = b'\x68' + struct.pack('<H', year) + b'\x00\x00'
def read_table(data, base):
"""把整張表的八筆讀出來,每筆回傳 (寬, 高, 色深, 保留欄)。
一筆 16 個位元組、四個 32 位元小端序數字,所以第 k 筆在 base + k * 16。
最後那個保留欄本站沒有量出它的用途,所以讀出來但不動它。
"""
out = []
for k in range(TABLE_ROWS):
w, h, dep, pad = struct.unpack_from('<IIII', data, base + k * RECORD)
out.append((w, h, dep, pad))
return out
def _mtime(p):
"""檔案的最後修改時間,印成 YYYY-MM-DD HH:MM。
備份是哪一天留下來的,是玩家判斷「這份是不是我這顆檔的備份」最直覺的線索,
所以每次提到備份都把它印出來。
"""
return datetime.datetime.fromtimestamp(
os.path.getmtime(p)).strftime('%Y-%m-%d %H:%M')
def _utc_date(ts):
"""把 PE 的 TimeDateStamp(1970-01-01 起算的秒數)換成 YYYY-MM-DD。
⚠️ 這裡刻意不用 datetime.utcfromtimestamp()。它從 Python 3.12 起被標成
deprecated:腳本跑到讀時間戳那一行時,畫面中間會插進兩行英文警告
(把輸出導到檔案或管線的話會跑到最上面)
(本站在 3.12.13 與 3.14.4 上各實測過一次,3.11.15 上沒有)——
退休球員看到那兩行會以為腳本壞了。
更重要的是官方寫明「未來版本會移除」。真的移除那天,這一行會掉進
呼叫端的 except,把一個好好的日期印成「不是合理的日期」——
那就從噪音變成假話,而這一課正是靠這個欄位告訴你「這顆檔被動過」。
(本站的餌:在 3.12 上加 -W error::DeprecationWarning 跑舊寫法,
同一顆檔的編譯時間戳當場變成「不是合理的日期」。)
改成自己從 1970-01-01 加秒數:一樣是 UTC、印出來一模一樣,
本站在 3.9 / 3.11 / 3.12 / 3.14 各跑過一次都沒有警告,
而這個寫法本身從來沒有被標成 deprecated 過。TimeDateStamp 是無號 32 位元,
最大 0xFFFFFFFF 也只到 2106 年,timedelta 加得動,不會溢位 ——
所以呼叫端那個 except 是保險,這個範圍裡叫不出例外來。
"""
return (datetime.datetime(1970, 1, 1) +
datetime.timedelta(seconds=ts)).strftime('%Y-%m-%d')
def edit_regions(data):
"""這支腳本會動到的每一段位元組,回傳 [(位移, 長度), ...];認不出結構就回傳 None。
五個旋鈕加上校驗值,總共就這幾格:
· Characteristics 2 個位元組(--4gb)
· CheckSum 4 個位元組(跟著重算的那一次)
· TimeDateStamp 4 個位元組(--timestamp)
· 記憶體池每一處的 imm32 各 4 個位元組(--pool)
· 解析度表第 1、2 筆的寬與高各 8 個位元組(--resolution)
· 王朝起始年份兩處各 2 個位元組(--dynasty-year)
· 時鐘版:甲處 5 個位元組與洞穴 133 個位元組(--dynasty-year clock;兩種狀態算出同一組格子)
位置全部從傳進來的那份資料自己算(不是寫死的),所以拿備份算出來的位置
對得上備份自己那個版本,不會把英文版的位址套到中文版上。
⚠️ 年份那兩處一定要在這裡。少了它,打過 --dynasty-year 的執行檔,之後
--apply 沿用舊備份、或 --restore 的時候,backup_mismatch() 會把
「年份那 2 到 4 個位元組不一樣」當成「這是別顆執行檔」,把正常流程擋掉。
"""
try:
pe, ch_off, ck_off = pe_offsets(data)
except DataError:
return None
regions = [(ch_off, 2), (ck_off, 4), (pe + 4 + 4, 4)]
for off, _v in find_pool(data):
regions.append((off, 4))
ya, yb = find_year_sites(data)
for off, _v in ya + yb:
regions.append((off, 2))
regions.extend(clock_regions(data))
try:
base = find_table(data)
except DataError:
# 找不到解析度表不影響判斷:那代表這支腳本在這顆檔上本來就改不到那張表,
# 於是「表的那幾格」也不該被算進允許範圍。
pass
else:
regions.append((base, 8))
regions.append((base + RECORD, 8))
return regions
def backup_mismatch(bak, live):
"""備份與現在這顆執行檔對不對得起來?對得起來回傳 None,對不起來回傳一句原因。
── 為什麼要有這一關(2026-09-05 加)────────────────────────────
備份只認檔名(執行檔路徑 + .modernizebak),不記它是哪一顆執行檔的備份。
玩家把 mvp2005.exe 換成另一顆變體(這一課自己就說「玩久了資料夾裡常常會有
一排執行檔」)卻沒刪舊備份,再跑一次 --apply,腳本會沿用那份**別顆執行檔**
的備份;之後 --restore 就把完全不同的二進位蓋上去,而且原本那幾道守門
一道都不會亮(本站實測:兩顆大小一樣、都是 MZ、節區表都對得上),
畫面還會印「已還原 ✅ / 內容與備份相同 ✅」—— 玩家那顆就這樣沒了。
「對得起來」的定義:大小一樣,而且兩份不同的位元組**全部**落在
edit_regions() 那幾格裡 —— 也就是「現在這顆看起來就是這份備份被這支腳本
改過的樣子」。反覆 --apply 幾次都還是落在那幾格裡,所以不會誤擋正常流程;
中間被別的工具動過(例如改節區旗標)就會落在外面,這一關就會亮。
"""
if len(bak) != len(live):
return '大小不一樣:備份 %d 位元組、現在這顆 %d 位元組。' % (len(bak), len(live))
if bak == live:
return None
regions = edit_regions(bak)
if regions is None:
return '讀不出備份的 PE 結構,沒辦法確認它是不是這顆檔的備份。'
allowed = set()
for off, ln in regions:
allowed.update(range(off, off + ln))
stray = [i for i in range(len(bak)) if bak[i] != live[i] and i not in allowed]
if stray:
return ('有 %d 個位元組對不上,而且不在這支腳本會動的那幾格裡(前幾個:%s)。'
% (len(stray), '、'.join('0x%06X' % i for i in stray[:8])))
return None
# ─────────────────────────────────────────────────────────
# 各種動作
# ─────────────────────────────────────────────────────────
def load(path):
"""整個檔一次讀進記憶體。執行檔只有幾 MB,一次讀完最單純,
也讓後面的複驗可以直接拿「原本的內容」跟「寫回去之後再讀出來的內容」逐位元組比。
"""
if not os.path.isfile(path):
raise DataError('找不到 %s' % path)
with open(path, 'rb') as f:
return f.read()
def describe(path):
"""把這個執行檔看得到的東西全部印出來,順便把後面要用的位置回傳出去。
回傳 (整個檔的內容, PE 起點, Characteristics 位置, CheckSum 位置,
解析度表起點, 解析度表八筆內容)。找不到解析度表時後兩項是 None,
這樣 --4gb 這種不需要那張表的動作照樣做得下去,不會被一起擋掉。
⚠️ 這個函式只讀不寫。各個旋鈕的寫入都在 apply_changes() 裡,
而且它也是先呼叫這一支拿到同一份資料,你看到的預覽跟真的會做的事才是同一件。
但「所有寫入」不只那一處:cmd_restore() 也會寫檔,而且不看 --apply,
守門過了就把備份寫成一個暫存檔、比對過內容才原子改名蓋回執行檔。
"""
data = load(path)
pe, ch_off, ck_off = pe_offsets(data)
# 三個欄位都是小端序:Machine 2 個位元組、Characteristics 2 個、CheckSum 4 個。
machine = struct.unpack_from('<H', data, pe + 4)[0]
chars = struct.unpack_from('<H', data, ch_off)[0]
stored_ck = struct.unpack_from('<I', data, ck_off)[0]
print(' 檔案 %s(%d 位元組)' % (os.path.basename(path), len(data)))
print(' 架構 0x%04X = %s' % (machine, '32 位元 x86' if machine == MACHINE_I386 else '不是 32 位元 x86'))
print(' 記憶體上限 %s'
% ('4 GB(大位址旗標已開)✅' if chars & LARGE_ADDRESS_AWARE else '2 GB(大位址旗標沒開)'))
print(' 檔頭校驗值 0x%08X%s' % (stored_ck, '(EA 沒有填,一般程式 Windows 不檢查)' if stored_ck == 0 else ''))
ts = struct.unpack_from('<I', data, pe + 4 + 4)[0]
try:
ts_txt = _utc_date(ts)
except Exception:
ts_txt = '不是合理的日期'
# MVP Baseball 2005 是 2005 年出的。時間戳落在那前後幾年以外,
# 就代表這個欄位後來被別的程式改過;本站不判斷是誰改的。
note = ''
if ts_txt[:4].isdigit() and not (2003 <= int(ts_txt[:4]) <= 2006):
note = ' ← 這不是真的編譯日期,是被改過的痕跡'
print(' 編譯時間戳 %s%s' % (ts_txt, note))
# 檢查碼欄位只印十六進位。
# 記憶體池找不到不算錯:有光碟保護的原版本來就搜不到樣態。
# 所以這裡只是印一行說明,不丟例外,--4gb 與解析度照樣可以做。
pool = find_pool(data)
if pool:
print(' 記憶體池 %d MB(遊戲自己切的那一塊,球場與字型都住在裡面)'
% (pool[0][1] // (1024 * 1024)))
else:
print(' 記憶體池:找不到(這個版本的樣態不一樣,或是有光碟保護)')
# 王朝起始年份:兩處寫死的年份。找不到同樣不算錯(有光碟保護的原版本來就找不到)。
st, cinfo = clock_state(data)
ya, yb = find_year_sites(data)
if st == 'clock':
print(' 王朝起始年份:跟著電腦時鐘走(開新王朝時讀電腦的日期,夾在 %d 到 %d;老闆模式維持 %d 起算)✅'
% (YEAR_MIN, YEAR_MAX, YEAR_MIN))
if len(yb) == 1 and yb[0][1] != YEAR_MIN:
print(' ⚠️ 年齡基準日(乙處)是 %d,時鐘版的設計是 %d ← 有人只改了其中一處' % (yb[0][1], YEAR_MIN))
elif st == 'foreign':
print(' 王朝起始年份:設年份那處被改成呼叫別處的程式,但那段程式不是本工具做的(%s);'
'本工具不碰這一顆的年份。' % cinfo['why'])
elif len(ya) == 1 and len(yb) == 1:
if ya[0][1] == yb[0][1]:
print(' 王朝起始年份 %d(程式碼裡寫死的兩處都是 %d)' % (ya[0][1], ya[0][1]))
else:
print(' 王朝起始年份:兩處不一致(設年份那處 %d、年齡基準日那處 %d)'
' ← 有人只改了其中一處' % (ya[0][1], yb[0][1]))
else:
print(' 王朝起始年份:找不到(設年份那處找到 %d 個、年齡基準日那處找到 %d 個;'
'有光碟保護的版本,或是這個版本的樣態不一樣)' % (len(ya), len(yb)))
# 同理,解析度表找不到也只是少一項功能,不是整支停擺。
# 把錯誤訊息印出來,回傳的表起點與內容給 None,由呼叫端決定要不要繼續。
try:
base = find_table(data)
except DataError as e:
print()
print(' 解析度表:%s' % e)
return data, pe, ch_off, ck_off, None, None
rows = read_table(data, base)
print()
print(' 解析度表在 0x%X(用內容找到的,不是寫死的位址):' % base)
for k, (w, h, dep, _) in enumerate(rows, 1):
print(' 第 %d 筆 %5d x %-5d %2d 位元色深' % (k, w, h, dep))
return data, pe, ch_off, ck_off, base, rows
def cmd_check(path):
"""沒帶任何旋鈕時走這裡:只體檢,然後告訴你有哪些旋鈕可以轉。
這條路徑上完全沒有寫檔的程式碼,所以「不小心改到東西」在這裡是不可能的。
"""
describe(path)
print()
print(' 以上全部是唯讀的,沒有動到任何東西。')
print(' 想改的話:')
print(' --4gb 把記憶體上限從 2 GB 提到 4 GB')
print(' --resolution 1920x1080 把解析度表裡最小的那一組換成你要的')
print(' --pool 128 把遊戲自己那塊記憶體池從 64 MB 調大')
print(' --dynasty-year 2026 把王朝模式寫死的起始年份 2005 換掉(範圍 %d 到 %d)'
% (YEAR_MIN, YEAR_MAX))
print(' --dynasty-year clock 王朝起始年份不寫死,開新王朝時讀電腦的日期(老闆模式維持 %d)' % YEAR_MIN)
def _print_fixed_year_notes(want_year):
"""寫死年份之後印的兩段說明:王朝能打幾季、老闆模式為什麼不一樣。"""
print()
print(' ⓘ 王朝(Franchise)模式:遊戲自己的上限是 %d 年,從 %d 起算約可打 %d 季。'
% (FRANCHISE_END_YEAR, want_year, FRANCHISE_END_YEAR - want_year))
if want_year > YEAR_MIN:
owner = OWNER_END_YEAR - want_year
if owner > 0:
print(' ⚠️ 老闆模式(Owner Mode)不一樣:上限是寫死的 %d 年,從 %d 起只能打 %d 季(依程式碼推算)。'
% (OWNER_END_YEAR, want_year, owner))
else:
print(' ⚠️ 老闆模式(Owner Mode)不一樣:上限是寫死的 %d 年,從 %d 起一開始就只剩「退休」。'
% (OWNER_END_YEAR, want_year))
print(' 第一天「擁有時間」也會顯示 %d 年。想完整玩老闆模式(30 年),請用沒改過年份的執行檔,'
'或改用 --dynasty-year clock(王朝跟著電腦時鐘走、老闆模式維持 %d)。'
% (want_year - YEAR_MIN, YEAR_MIN))
def _print_clock_notes():
"""時鐘版之後印的說明:會做什麼、實測過什麼、沒測什麼。"""
print()
print(' ⓘ 王朝(Franchise)模式:開新王朝時,起始年份 = 電腦現在的年份,夾在 %d 到 %d 之間。'
% (YEAR_MIN, YEAR_MAX))
print(' 2026 年 10 月本站實測:電腦日期是當時的 2026 → 開出 2026;調成 2030 → 2030;'
'調成 2001 → 夾回 %d。' % YEAR_MIN)
print(' %d 這一側的夾限沒有實機測過。' % YEAR_MAX)
print(' ⓘ 老闆模式(Owner Mode)不跟著走,維持原版的 %d 起算:30 年上限與「前進至下一日」'
'都和原版一樣(本站實測過一次)。' % YEAR_MIN)
print(' ⓘ 賽程檔不用另外換(實測:賽程檔是 %d 年,開出來的是電腦的年份);'
'舊存檔載得進去,日期是存檔自己的。' % YEAR_MIN)
print(' ⚠️ 只在一台 Windows 機器上實測過;Wine / macOS 沒測。')
def _plan_dynasty_year(out, todo, want_year):
"""旋鈕 5(--dynasty-year)的算法:在記憶體裡的複本 out 上改,要做的事加進 todo。
want_year 是 2005 到 2100 的整數(寫死成那一年),或 YEAR_CLOCK(跟著電腦時鐘)。
甲處現在有三種長相:原版的「push 年份」(clock_state 回 None)、時鐘版(回 'clock')、
不是本工具做的(回 'foreign',不碰)。乙處永遠是 2 個位元組的年份。四種組合各走各的:
寫死 ← push :兩處一起改成同一個值(原本的行為)
寫死 ← 時鐘 :甲處換回 push、洞穴清成 0、乙處寫同一個年份 ——
結果和直接從沒打過時鐘的檔改成這個年份逐位元組相同
時鐘 ← push :甲處換成 call、洞穴寫進去、乙處改回 2005
時鐘 ← 時鐘 :本來就是,不用做
"""
clock = want_year == YEAR_CLOCK
# 範圍先檢查再找位置:超出範圍的數字不需要讀檔就知道不行。
if not clock and not (YEAR_MIN <= want_year <= YEAR_MAX):
raise DataError('王朝起始年份只接受 %d 到 %d(或 clock),你給的是 %s。\n'
' 上限 %d 是本站在實機驗過的最大值(遊戲自己的王朝上限是 %d 年,'
'2101 到 %d 理論上可以、但沒測,所以不開放)。'
% (YEAR_MIN, YEAR_MAX, want_year, YEAR_MAX, FRANCHISE_END_YEAR,
FRANCHISE_END_YEAR - 2))
st, cinfo = clock_state(out)
if st == 'foreign':
raise DataError('設年份那處已經被改成「呼叫別處的程式」,但那段程式不是本工具做的:\n'
' %s\n'
' 不知道它在做什麼,所以不碰。想換回來就 --restore(如果有備份)。' % cinfo['why'])
ya, yb = find_year_sites(out)
if len(yb) != 1 or (st is None and len(ya) != 1):
raise DataError('這個執行檔裡沒有剛好各找到一個寫死王朝年份的位置'
'(設年份那處%s、年齡基準日那處找到 %d 個),沒辦法改。\n'
' 有光碟保護的原版執行檔找不到是正常的,本工具不適用;\n'
' 如果是找到不只一個,代表不確定該改哪一個,所以停下來,不拿位址去猜。'
% ('已經是時鐘版' if st == 'clock' else '找到 %d 個' % len(ya), len(yb)))
off_b, cur_b = yb[0]
if clock:
if st == 'clock':
if cur_b == YEAR_MIN:
print()
print(' ⓘ 王朝起始年份本來就是跟著電腦時鐘走,這一項不用做。')
else:
struct.pack_into('<H', out, off_b, YEAR_MIN)
todo.append('把年齡基準日(乙處)從 %d 改回 %d(時鐘版的設計)' % (cur_b, YEAR_MIN))
_print_clock_notes()
return
try:
plan = clock_plan(out)
except (DataError, struct.error) as e:
raise DataError('沒辦法把這顆改成時鐘版:%s\n'
' (不是這一族的執行檔,或是結構跟本站量過的對不上;本工具不猜,停下來。)' % e)
cur_a = ya[0][1]
clock_install(out, plan)
if cur_b != YEAR_MIN:
struct.pack_into('<H', out, off_b, YEAR_MIN)
todo.append('把王朝起始年份從寫死的 %d 改成跟著電腦時鐘走(甲處 5 個位元組換成呼叫、'
'洞穴寫入 %d 個位元組%s)'
% (cur_a, CLOCK_CAVE_LEN,
'' if cur_b == YEAR_MIN else '、年齡基準日從 %d 改回 %d' % (cur_b, YEAR_MIN)))
_print_clock_notes()
return
# ── 寫死成一個年份 ──
if st == 'clock':
clock_remove(out, cinfo, want_year)
struct.pack_into('<H', out, off_b, want_year)
todo.append('把王朝起始年份從「跟著電腦時鐘走」改回寫死的 %d(甲處換回 push 年份、'
'洞穴 %d 個位元組清成 0、年齡基準日寫 %d)' % (want_year, CLOCK_CAVE_LEN, want_year))
_print_fixed_year_notes(want_year)
return
# 只寫 16 位元年份的 2 個位元組;甲處的 push 立即數後面兩個位元組是 00 00(樣態要求過),
# 年份最大 2100 = 0x0834 不會碰到它們。
sites = [ya[0], yb[0]]
if all(v == want_year for _, v in sites):
print()
print(' ⓘ 王朝起始年份本來就是 %d,這一項不用做。' % want_year)
return
cur_a, cur_b = ya[0][1], yb[0][1]
cur_txt = ('%d' % cur_a if cur_a == cur_b
else '設年份那處 %d、年齡基準日那處 %d' % (cur_a, cur_b))
for off, _ in sites:
struct.pack_into('<H', out, off, want_year)
todo.append('把王朝起始年份從 %s 改成 %d(兩處,各寫入一個 16 位元的年份)' % (cur_txt, want_year))
_print_fixed_year_notes(want_year)
def apply_changes(path, want_4gb, want_res, want_pool, want_stamp, apply_it,
want_year=None, keep_checksum=False):
"""算出這一次要改哪些位元組,列出來,apply_it 是真的時才寫下去。
流程是固定的一條線:先唯讀描述一次 → 在記憶體裡的複本 out 上改
→ 把「改了幾個位元組、在哪幾個位置」列出來(位置只列前 16 個) → 沒有 --apply 就到此為止
→ 有 --apply 才備份、寫檔、重新讀回來複驗。
orig 留著原始內容整份不動,最後拿它跟「寫完再讀出來的檔」逐位元組比。
這樣就算中間哪一段算錯位置,也會在複驗那一關被抓出來,
而不是等你進遊戲才發現。
每一個旋鈕都先看「現在是不是已經是你要的值」,是的話就印一行說不用做,
不會白白產生一次寫入。
"""
if apply_it:
# 要寫的那一刻才擋(唯讀體檢與預覽只讀不寫,SPEC 明訂讀取不限制),
# 但要擋在「讀檔」之前:連結指到的東西不是執行檔時,玩家該看到的是
# 「這是一條捷徑」,不是「開頭不是 MZ」——後者會讓人以為是遊戲檔壞了。
_reject_symlink(path, '遊戲執行檔')
data, pe, ch_off, ck_off, base, rows = describe(path)
orig = bytes(data)
# out 是可以改的複本,orig 是拿來比對的原件,兩份都在記憶體裡,
# 檔案本身要到最後面確定要寫的時候才會被碰。
out = bytearray(data)
todo = []
# ── 旋鈕 1:大位址旗標 ──
# 讀出 Characteristics 這 2 個位元組,把 0x0020 這個位元 or 進去再寫回原位。
# 檔案長度不變,其他位元也不變。
if want_4gb:
chars = struct.unpack_from('<H', out, ch_off)[0]
if chars & LARGE_ADDRESS_AWARE:
print()
print(' ⓘ 大位址旗標本來就已經開了,這一項不用做。')
else:
struct.pack_into('<H', out, ch_off, chars | LARGE_ADDRESS_AWARE)
todo.append('把記憶體上限從 2 GB 提到 4 GB(改 Characteristics 的 1 個位元)')
# ── 旋鈕 2:編譯時間戳 ──
# 使用者給的是 YYYY-MM-DD,要換算成 1970-01-01 起算的秒數再寫成 4 個位元組。
if want_stamp is not None:
# PE 檔頭的 TimeDateStamp。Windows 載入一般程式時不看它,
# 但它是這個檔「來歷」的線索之一 —— 改掉之前請先讀下面那段警告。
try:
y, mo, dy = [int(x) for x in want_stamp.split('-')]
epoch = int((datetime.datetime(y, mo, dy) -
datetime.datetime(1970, 1, 1)).total_seconds())
except Exception:
raise DataError('時間戳要寫成 YYYY-MM-DD,例如 2026-08-29。你給的是「%s」' % want_stamp)
# TimeDateStamp 是無號 32 位元,只放得下 1970-01-01 到 2106 年之間。
# 不先擋的話,--timestamp 1960-01-01 的日期字串本身是合法的,
# 會一路走到 struct.pack_into 才炸成 Python 追蹤訊息。
if not (0 <= epoch <= 0xFFFFFFFF):
raise DataError('PE 的時間戳是無號 32 位元,只放得下 1970-01-01 到 '
'2106-02-07 之間的日期。你給的是「%s」' % want_stamp)
ts_off = pe + 4 + 4
cur_ts = struct.unpack_from('<I', out, ts_off)[0]
try:
cur_txt = _utc_date(cur_ts)
except Exception:
cur_txt = '0x%08X(不是合理的日期)' % cur_ts
if cur_ts == epoch:
print()
print(' ⓘ 時間戳本來就是 %s,這一項不用做。' % want_stamp)
else:
struct.pack_into('<I', out, ts_off, epoch)
todo.append('把編譯時間戳從 %s 改成 %s(改 4 個位元組)' % (cur_txt, want_stamp))
# ── 旋鈕 3:記憶體池 ──
# 找到的每一處都要改成同一個值。本站量到的是「1 比 0」:一處有直接呼叫者、
# 另一處沒有(細節見 find_pool 的說明),但沒有反組譯就不能斷定沒人呼叫的
# 那一處是死碼;兩處本來就是同一個值,所以一起改最保險。
if want_pool is not None:
sites = find_pool(out)
if not sites:
raise DataError('這個執行檔裡找不到記憶體池的樣態,沒辦法改。'
'有光碟保護的原版執行檔本工具不適用。')
if not (64 <= want_pool <= 1024):
raise DataError('記憶體池只接受 64 到 1024 MB,你給的是 %s。' % want_pool)
new = want_pool * 1024 * 1024
cur = sites[0][1]
if all(v == new for _, v in sites):
print()
print(' ⓘ 記憶體池本來就是 %d MB,這一項不用做。' % want_pool)
else:
for off, _ in sites:
struct.pack_into('<I', out, off, new)
todo.append('把記憶體池從 %d MB 改成 %d MB(%d 處,各改 4 個位元組)'
% (cur // (1024 * 1024), want_pool, len(sites)))
# ── 旋鈕 4:解析度 ──
# 只換寬與高(每筆前 8 個位元組),色深與保留欄照原樣留著。
# 表裡本來就有你要的尺寸就不做,免得白改一筆。
if want_res is not None:
if base is None:
raise DataError('這個執行檔裡找不到解析度表,沒辦法改解析度。')
try:
w_s, h_s = want_res.lower().split('x')
w, h = int(w_s), int(h_s)
except Exception:
raise DataError('解析度要寫成「寬x高」,例如 1920x1080。你給的是「%s」' % want_res)
if not (640 <= w <= 7680 and 480 <= h <= 4320):
raise DataError('%dx%d 超出合理範圍(寬 640-7680、高 480-4320),不敢寫。' % (w, h))
if any(r[0] == w and r[1] == h for r in rows):
print()
print(' ⓘ 表裡本來就有 %dx%d,這一項不用做。' % (w, h))
else:
# 換掉最小的那一組(640x480 的 16 與 32 位元兩筆)。
# 為什麼換最小的:那是今天不會有人選的尺寸,換掉它損失最小。
for k in (0, 1):
off = base + k * RECORD
struct.pack_into('<II', out, off, w, h)
todo.append('把第 1、2 筆的 %dx%d 換成 %dx%d(各改 8 個位元組)'
% (rows[0][0], rows[0][1], w, h))
# ── 旋鈕 5:王朝起始年份 ──
# 寫死成一個年份,或 clock = 跟著電腦時鐘走;細節在 _plan_dynasty_year。
if want_year is not None:
_plan_dynasty_year(out, todo, want_year)
if not todo:
print()
print(' 沒有事情要做。')
return
print()
print(' 要做的事:')
for t in todo:
print(' · %s' % t)
# 校驗值:原本是 0 就維持 0(EA 就沒填),原本有值才重算。
# 這裡刻意用 orig 讀原本的值,不是用已經改過的 out。要判斷的是
# 「這個檔本來有沒有填」,不是「我剛剛有沒有動到它」。
#
# --keep-checksum(2026-10-29 加)是給「那 4 個位元組本來就不是真的校驗值」的檔用的:
# 有的執行檔在這個欄位放的是別的東西,重算會把它蓋成一個我們自己算出來的新值。
# 預設行為完全不變(原本有填就重算);只有明講 --keep-checksum 才原樣留著。
stored_ck = struct.unpack_from('<I', orig, ck_off)[0]
if stored_ck != 0:
if keep_checksum:
print(' · 檔頭校驗值原樣保留(你指定了 --keep-checksum,不重算)')
else:
struct.pack_into('<I', out, ck_off, pe_checksum(bytes(out), ck_off))
print(' · 重算檔頭校驗值(原本有填,所以要跟著更新)')
# 逐位元組比出「到底哪幾個位置變了」。不用自己記帳,因為記帳會漏。
# 這樣算出來的清單等一下要拿去跟寫回檔案後的實際結果對,兩邊必須一模一樣。
changed = [i for i in range(len(orig)) if orig[i] != out[i]]
print()
print(' 總共會改 %d 個位元組,檔案大小不變(%d)。' % (len(changed), len(out)))
# 位置只列前 16 個,畫面才不會被洗掉。總數以上面那一行為準。
# 下面複驗比的是完整的 changed,不是這裡列出來的這幾個。
print(' 位置:%s' % '、'.join('0x%06X' % i for i in changed[:16]))
if not apply_it:
print()
print(' 以上是預覽,還沒有動到任何檔案。')
print(' 確定要改的話,在剛才那一行最後面加上 --apply')
return
# 到這裡才第一次碰到檔案系統。先備份,而且只在還沒有備份時才建立:
# 第二次、第三次 --apply 都保留最早那一份,也就是你動手前的原版。
# 遊戲執行檔的符號連結守門在這個函式最上面(要寫的時候、讀檔之前)。
# 它以前擋不到:main() 會先把符號連結 realpath 成真檔再傳進來,
# 等於「跟著連結寫」。2026-09-06 起 main() 不再 realpath,這一道才真的有用。
backup = path + BACKUP_SUFFIX
# 備份檔名是算得出來的(執行檔路徑 + 固定尾巴),所以它是最容易被事先佔住的
# 那個名字。先擋符號連結,而且用 lexists 不用 exists ——
# 一條指到不存在的東西的連結,exists() 會回 False,那就會被當成「沒有備份」
# 直接往下走,寫進去就寫到連結指的地方去了。
_reject_symlink(backup, '備份檔')
if not os.path.lexists(backup):
_atomic_copy(path, backup)
print()
print(' 已備份 → %s' % os.path.basename(backup))
else:
print()
print(' 備份已存在,保留最早那一份 → %s(%d 位元組,%s)'
% (os.path.basename(backup), os.path.getsize(backup), _mtime(backup)))
# 沿用舊備份之前先確認它真的是「現在這顆執行檔」的備份。對不上就停下來,
# 不在沒有正確備份的情況下動玩家的檔(理由見 backup_mismatch 的說明)。
why = backup_mismatch(load(backup), orig)
if why is not None:
raise DataError(
'這份備份跟現在這顆執行檔對不上,所以我不敢在沒有正確備份的情況下動它。\n'
' %s\n'
' 備份:%s(%d 位元組,%s)\n'
' 現在:%s(%d 位元組,%s)\n'
' 多半是你換過另一顆執行檔,而舊的備份還留在旁邊。\n'
' 請先把舊備份改名或搬到別的資料夾,再跑一次這一行 ——\n'
' 腳本就會替現在這顆重新建一份備份。'
% (why,
os.path.basename(backup), os.path.getsize(backup), _mtime(backup),
os.path.basename(path), len(orig), _mtime(path)))
# 寫入本身也走「先寫暫存檔再改名」:改名是原子的,
# 所以遊戲執行檔要嘛是舊的、要嘛是完整的新的,不會出現寫到一半的檔。
# 暫存檔的名字以前是 path + '.tmp' —— 算得出來,就有人可以先把那個名字
# 放成一條指向資料夾外面的捷徑,open(..., 'wb') 會跟著它去把外面那個檔截斷。
# 現在用 mkstemp 開在同一個資料夾裡(隨機名字 + O_CREAT|O_EXCL),佔不住也跟不到。
fd, tmp = _mkstemp_beside(path, 'apply')
try:
with os.fdopen(fd, 'wb') as f:
f.write(bytes(out))
f.flush()
os.fsync(f.fileno()) # 真的落到碟上,不是只到作業系統的快取
# 新開的暫存檔拿的是系統預設權限(umask),直接改名過去會把執行檔原本的
# 權限換掉 —— 本站實測 755 變成 644,Wine / Linux 那條路上就少了可執行位元。
# 所以改名之前先把原本那顆的權限抄過來。只抄權限,不動時間戳:
# 「這個檔今天被改過」本來就該看得出來。
try:
shutil.copymode(path, tmp)
except OSError:
# FAT32 隨身碟、某些網路磁碟不讓你設權限。抄不過去就算了 ——
# 這一步是加分,不是必要,不能因為它讓整次修改失敗。
print(' ⓘ 這個檔案系統不讓我設定權限,新的執行檔用的是預設權限。')
_begin_replace(path, 'apply') # 這一行之後,「什麼都沒動到」就不再保證是真的
with _NoInterrupt(): # 改名 + 登記中間不接受 Ctrl-C(見 _NoInterrupt)
os.replace(tmp, path)
_mark_replaced() # 這一行之後,「什麼都沒動到」就確定不是真的
except BaseException:
# 寫到一半出事(磁碟滿、外接碟被拔掉、按了 Ctrl-C):把半截的暫存檔收掉
# 再把例外往上丟,不要在玩家的遊戲資料夾裡留一個看起來像執行檔的垃圾。
# 走到這裡有兩種可能,而分辨它們的是登記本身,不是這個 handler 的猜測:
# (1) 改名還沒跑(還沒進不可中斷區,或 os.replace 自己丟例外)——
# 登記停在「正在換」,_abandon_replace() 把它收回「還沒動」,
# 收尾就可以誠實地說「一個位元組都沒有動到」;
# (2) 改名跑完了,_NoInterrupt 離開那一段之後才把 Ctrl-C 丟出來 ——
# 那時登記已經是「已換」,_abandon_replace() 不會去動它,
# 收尾訊息照三態說實話。
try:
os.close(fd) # 上面那個 with 已經關掉時會丟 EBADF
except OSError:
pass
_unlink_quiet(tmp)
_abandon_replace()
raise
# ── 複驗:重新讀回來,而且確認「只有預期的位元組變了」──
back = load(path)
if len(back) != len(orig):
raise DataError('寫入後檔案大小變了,請立刻還原:\n %s' % _restore_hint(path))
actual = [i for i in range(len(orig)) if orig[i] != back[i]]
if actual != changed:
# 複驗只要有一項對不上就當失敗:訊息裡直接給可以複製的還原指令,
# 而且往上丟 DataError → main() 回傳 2,exit code 不會是 0。
raise DataError('實際改動的位元組跟預期不符(預期 %d 個、實際 %d 個),請立刻還原:\n %s'
% (len(changed), len(actual), _restore_hint(path)))
print(' 已寫入。複驗:改動的位元組剛好是預期那 %d 個,其他一個都沒動 ✅' % len(actual))
print()
print(' 進遊戲看看。沒效果或有問題就 --restore。')
def cmd_restore(path):
"""把備份蓋回去,但覆蓋之前先確認那份備份自己是好的、而且真的是這顆檔的備份。
三道:
1. 這裡先看開頭是不是 MZ(拿錯檔就擋下來)。
2. 這份備份對不對得上現在這顆執行檔(backup_mismatch):大小要一樣,而且
兩份不同的位元組要全部落在這支腳本會動的那幾格裡。2026-09-05 加 ——
在這之前,玩家換過執行檔卻沒刪舊備份,--restore 會把一顆完全不同的
二進位蓋上去,還印「內容與備份相同 ✅」。執行檔不在了(被刪掉)就跳過
這一道,那種時候「拿備份把它變回來」正是玩家要的。
3. 交給 _restore_from_backup(),由它去驗 PE 節區表指到的長度有沒有
超出檔案本身,那一道是擋半截備份的關鍵。
還原完再讀一次兩邊比對,不同就當成失敗要你手動檢查。
"""
backup = path + BACKUP_SUFFIX
_reject_symlink(backup, '備份檔')
_reject_symlink(path, '要還原的遊戲執行檔')
if not os.path.lexists(backup):
raise DataError('找不到備份 %s —— 沒有東西可以還原。' % os.path.basename(backup))
with open(backup, 'rb') as f:
if f.read(2) != b'MZ':
raise DataError('備份檔開頭不是 MZ,不敢拿它覆蓋。')
if os.path.isfile(path):
why = backup_mismatch(load(backup), load(path))
if why is not None:
raise DataError(
'這份備份跟現在這顆執行檔對不上,不敢拿它覆蓋。\n'
' %s\n'
' 備份:%s(%d 位元組,%s)\n'
' 現在:%s(%d 位元組,%s)\n'
' 多半是你換過另一顆執行檔,而舊的備份還留在旁邊 ——\n'
' 蓋下去的話,現在這顆就沒了。\n'
' 真的要用這份備份的話,請自己在檔案總管 / Finder 裡\n'
' 把 %s 複製成 %s,那樣你至少看得到自己在蓋掉什麼。'
% (why,
os.path.basename(backup), os.path.getsize(backup), _mtime(backup),
os.path.basename(path), os.path.getsize(path), _mtime(path),
os.path.basename(backup), os.path.basename(path)))
_restore_from_backup(backup, path)
same = open(backup, 'rb').read() == open(path, 'rb').read()
print(' 已還原 ← %s' % os.path.basename(backup))
print(' 複驗:內容與備份%s' % ('相同 ✅' if same else '不同 ❌'))
if not same:
raise DataError('還原後內容跟備份不一樣,請手動檢查。')
def _year_arg(s):
"""--dynasty-year 的型別:2005 到 2100 的整數,或 clock(大小寫不拘)。範圍由 apply_changes 擋。"""
if s.strip().lower() == YEAR_CLOCK:
return YEAR_CLOCK
try:
return int(s)
except ValueError:
raise argparse.ArgumentTypeError(
'要寫年份(整數,%d 到 %d)或 clock,你給的是「%s」' % (YEAR_MIN, YEAR_MAX, s))
def main():
"""指令列入口:決定這一次走還原、走修改,還是走唯讀體檢。
三條路互斥,而且順序是有意義的:
--restore 最優先(出事時那一行一定要能跑),
接著是「有沒有帶任何一個旋鈕」,都沒有才落到唯讀體檢。
DataError 與 OSError(權限不足、磁碟滿、外接碟被拔掉)一律在這裡收成
一句人看得懂的話並回傳 2,不讓 Python 的追蹤訊息噴給玩家。
Ctrl-C 回傳 130,那是慣例。
傳進來的路徑如果本身是符號連結(捷徑),要寫的那兩條路(--apply / --restore)
直接停下來,不跟著連結寫;唯讀體檢與預覽只讀不寫,照樣看得下去。
--selftest 不走這裡(在最底下就先攔下來了)。
"""
EPILOG = (
"\n例子(照順序做):\n\n"
" 1. 先看你手上那份是什麼(完全唯讀)\n"
" python3 -I mvp_modernize.py \"<遊戲資料夾>/mvp2005.exe\"\n\n"
" 2. 預覽要改什麼(還是不會動到檔案)\n"
" python3 -I mvp_modernize.py \"<...>/mvp2005.exe\" --4gb --resolution 1920x1080\n\n"
" 3. 確定了才真的改\n"
" python3 -I mvp_modernize.py \"<...>/mvp2005.exe\" --4gb --resolution 1920x1080 --apply\n\n"
" 出問題就還原:\n"
" python3 -I mvp_modernize.py \"<...>/mvp2005.exe\" --restore\n"
)
ap = argparse.ArgumentParser(
description='讓 MVP Baseball 2005 在今天的機器上跑得順(記憶體上限與解析度)',
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=EPILOG)
ap.add_argument('exe', help='遊戲執行檔的完整路徑(mvp2005.exe)')
ap.add_argument('--4gb', dest='four_gb', action='store_true', help='把記憶體上限從 2 GB 提到 4 GB')
ap.add_argument('--resolution', metavar='寬x高', help='換一個解析度進去,例如 1920x1080')
ap.add_argument('--timestamp', metavar='YYYY-MM-DD',
help='改 PE 檔頭的編譯時間戳。⚠️ 那是這個檔來歷的線索之一,'
'改掉之前先看程式開頭的說明')
ap.add_argument('--pool', type=int, metavar='MB',
help='把遊戲自己那塊記憶體池從 64 MB 調大(64-1024)。'
'中文語系配大球場會當,就是被這個卡住')
ap.add_argument('--dynasty-year', dest='dynasty_year', type=_year_arg, metavar='年份|clock',
help='把王朝模式寫死的起始年份 2005 換成你要的年份(%d-%d),'
'或寫 clock = 開新王朝時讀電腦的日期(老闆模式維持 %d)。'
'重新走一遍 4GB 流程之後,王朝又變回 2005,就是這個' % (YEAR_MIN, YEAR_MAX, YEAR_MIN))
ap.add_argument('--keep-checksum', dest='keep_checksum', action='store_true',
help='改的時候不重算 PE 檔頭的校驗值,原本寫什麼就留什麼'
'(預設:原本不是 0 就跟著重算)。要搭配上面任何一個旋鈕用')
ap.add_argument('--apply', action='store_true', help='真的寫入(沒加就只是預覽)')
ap.add_argument('--restore', action='store_true', help='從備份還原')
args = ap.parse_args()
# ── 2026-09-06:路徑本身是符號連結時「拒絕」,不是 realpath 之後照寫 ──
# 這裡以前是 exe = os.path.realpath(exe),理由是「不換的話 os.replace 會把
# 連結本身換成一般檔案」。那個理由沒錯,但解法選錯邊了:跟著連結寫,等於
# 讓一條放在遊戲資料夾裡的捷徑,把這支腳本導去改資料夾外面的任何一個檔。
# 現在改成:**要寫**的路徑是符號連結就停下來(由 apply_changes / cmd_restore
# 各自的 _reject_symlink 擋),讀取不限制 —— 唯讀體檢與預覽照樣看得下去。
# 只看最後那一層:上層資料夾是連結不影響(有人把遊戲放在別顆碟)。
exe = args.exe
try:
if args.restore:
cmd_restore(exe)
elif (args.four_gb or args.resolution is not None
or args.pool is not None or args.timestamp is not None
or args.dynasty_year is not None):
# 2026-08-28 修:加 --pool 時忘了加進這個條件,
# 單獨給 --pool 會安靜地掉到唯讀檢查 —— 使用者會以為改了但其實沒有。
# (同一種錯 mvp_shrink_big.py 的 --apply 也犯過,toni 實際踩到)
# 2026-09-05 修完另一半:當時寫成 `or args.pool`,而 --pool 0 的 0
# 是 falsy,照樣安靜掉到唯讀體檢、exit 0,一個字都不提示
#(--resolution "" 同理)。要用 `is not None`,「有沒有給這個旗標」
# 才跟「給的值是不是真的」分得開 —— 值對不對由下面各自的檢查去擋。
apply_changes(exe, args.four_gb, args.resolution, args.pool, args.timestamp, args.apply,
args.dynasty_year, args.keep_checksum)
elif args.keep_checksum:
# --keep-checksum 只是「改檔時別動校驗值」的修飾,自己什麼都不改。
# 單獨給它不可以安靜地掉回唯讀體檢 —— 使用者會以為已經改了。
raise DataError('--keep-checksum 要搭配別的旋鈕用(--4gb / --resolution / --pool / '
'--timestamp / --dynasty-year),單獨給它什麼都不會做。')
else:
cmd_check(exe)
except DataError as e:
print('\n 停下來了:%s\n' % e)
return 2
except OSError as e:
# 作業系統不讓我們讀寫(遊戲裝在 Program Files 這種要管理員權限的位置、
# 磁碟滿了、外接碟被拔掉)。原本這裡沒收,玩家看到的是 Python 追蹤訊息。
# 不寫「你的檔沒事」這種保證,寫玩家自己看得到的判準:
# 「已寫入」那一行是 os.replace 成功之後才印的。
print('\n 停下來了:作業系統不讓我讀寫檔案 —— %s\n'
' 常見原因:遊戲裝在 Program Files 這種需要管理員權限的位置、'
'磁碟滿了、或是外接碟被拔掉。\n'
'%s\n' % (e, _replace_state_note(exe)))
return 2
except KeyboardInterrupt:
# 「什麼都沒有動到」只有在改名還沒開始的時候才是真的。
# 已經換過、或中斷時正在換,都要老實說,並且把還原指令給他。
if _PROGRESS['stage'] == 'idle':
print('\n 已中斷,遊戲執行檔一個位元組都沒有動到。\n')
else:
print('\n 已中斷 ——\n%s\n' % _replace_state_note(exe))
return 130
return 0
# ─────────────────────────────────────────────────────────
# 自我測試(--selftest,2026-09-24 加)
# ─────────────────────────────────────────────────────────
def selftest():
"""--selftest:在系統暫存資料夾自己造一顆最小的假執行檔,把 main() 走得到的每一道守門各驗一次。全過回 0。
⚠️ 不碰你的遊戲。那顆假執行檔只有 8 KB,是下面照 PE 格式拼出來的
(MZ 檔頭、PE 檔頭、一個節區、一張解析度表、兩處記憶體池),不是 EA 的程式;
全部放在 tempfile.mkdtemp() 開的那一個資料夾裡,跑完整個刪掉。
這支走得到的每一道守門都有「餌」(故意做它該擋的事,沒擋就紅)與「陰性對照」
(做正常的事,它不該擋;少了這一半,一個什麼都擋的版本也會全綠)。
共用的還原函式 _restore_from_backup() 裡有幾道這支走不到,所以沒有餌:
「找不到備份」與「備份是 0 bytes」在 cmd_restore() 就先被擋下來了;
BIGF 與語系檔(LOCH)那兩段是給別種檔案用的;「備份不到正本的一半」那道地板
要正本還在才比,而正本還在時 cmd_restore() 已經先要求兩份一樣大。
一、認不得的檔:路徑不存在、開頭不是 MZ、找不到 PE 檔頭、檔頭讀到一半就沒了 → 停下來
二、用內容找東西:解析度表找不到或找到兩處 → 停下來;記憶體池只認
「1 MB 的整數倍、落在 64 到 1024 MB 之間」的那一種;
找不到記憶體池的檔給 --pool → 停下來
三、參數:--pool、--resolution、--timestamp 超出範圍或寫法不對 → 停下來;
--pool 0 不可以安靜地掉回唯讀體檢
四、預覽一個位元組都不寫;--apply 只動該動的那幾格、檔案大小不變、備份等於原檔、
原本有填的 CheckSum 會重算、原本是 0 的維持 0;同一件事再做一次不會再寫
五、找不到解析度表時,只有 --resolution 停下來,--4gb 照樣做得到
六、旁邊躺著一份「別顆執行檔」的備份 → --apply 與 --restore 都停下來
七、換檔那一步失敗 → 執行檔原封不動、不留暫存檔、收尾不說假話
八、Ctrl-C:還沒換就按 → 說沒動到;換好之後才收到真的 SIGINT → 修改那條路要說
已經換好了並給還原指令,還原那條路要說還原已經做完了、不叫人再還原一次
九、還原:找不到備份、備份不是 MZ、正本不見了而備份又是半截的(或 PE 簽章不見了、
節區表被截斷)→ 停下來;還原換檔失敗 → 正本原封不動、收尾不說假話;
正常還原逐位元組回去
十、符號連結:執行檔、備份是連結 → --apply 與 --restore 停下來,不跟著連結寫
(這台機器做不出符號連結就跳過,而且會印出來說跳過了)。
四個餌裡有三個擋在兩層(呼叫端一道、真正寫檔的函式裡一道;只有「--apply 時
執行檔是連結」是一層),餌驗的是兩層合起來的結果:只拆掉其中一層,
另一層照樣擋,這裡不會紅
十一、寫檔途中與寫完之後:備份只複製了一半 → 不當成備份、不動執行檔;
寫進去之後讀回來大小變了、或多動了一個位元組 → 回 2 並給還原指令;
設不了權限的檔案系統 → 印一行照樣寫完;還原的暫存檔讀回來跟備份
對不起來 → 不換上去;還原換好之後讀回來跟備份不一樣 → 回 2
十二、王朝起始年份:兩處都找得到才改、只改那兩處;超出 2005 到 2100 / 一處都找不到 /
只找到一處 / 其中一處找到兩個 → 停下來,而且同一行的別的旋鈕也不套用;
兩處不一致時只補齊不一致的那一處;打過年份補丁的檔再打一次、再還原都不會被
當成「別顆執行檔」
十三、--keep-checksum:不重算校驗值、原樣保留,改動只剩該改的位元組;單獨給它不可以
安靜地掉回唯讀體檢;預設行為(原本有填就重算)沒變
十四、時鐘年份(--dynasty-year clock):洞穴的機器碼跟 Windows 實測通過的那一份逐位元組相同;
打上去只動甲處與洞穴、備份與還原逐位元組回到原樣;時鐘版 ↔ 寫死年份互轉,結果跟直接從原版
改逐位元組相同;洞穴不是本工具做的 / 尾端放不下 / 節區不可執行 / 缺 GetProcAddress /
兩支模式判斷的結構對不上 / 甲處乙處找不到或找到兩個 → 停下來,同一行的別的旋鈕也不套用
十五、最上面那道「-O 拒跑」守門自己的餌
在 python3 -O 底下拒跑,回 2。
"""
# -O 會把 assert 整段拿掉。這一段的判斷是下面那個 check(),-O 拿不掉它 ——
# 守門照樣要有:哪天有人在這裡補一句 assert,沒有它就會靜靜地變成假綠。
if _optimize_level():
print('--selftest 不能在 python3 -O 底下跑:-O 會把 assert 整段拿掉,'
'測試會變成一片假的綠燈。請拿掉 -O 再跑一次。')
return 2
# 這一行要緊接在守門後面:_optimize_bait() 的陰性對照靠它收工(見那個函式)。
_opt = _optimize_bait(selftest)
import contextlib
import io
import threading
print('自我測試(在系統暫存資料夾裡造一顆假的執行檔,不碰你的遊戲)')
tally = {'n': 0, 'bait': 0, 'fail': 0}
skipped = []
def check(ok, what, bait=False):
tally['n'] += 1
tally['bait'] += 1 if bait else 0
tally['fail'] += 0 if ok else 1
print(' %s %s%s' % ('✅' if ok else '❌', '餌:' if bait else '', what))
def quiet(fn, *a):
"""跑 fn,把它印的字收起來。回傳 (回傳值或它丟出來的例外, 印出來的字)。"""
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
r = fn(*a)
except (Exception, SystemExit, KeyboardInterrupt) as e:
r = e
return r, buf.getvalue()
def stops(fn, *a):
return isinstance(quiet(fn, *a)[0], DataError)
def put(path, data):
with open(path, 'wb') as f:
f.write(data)
def get(path):
with open(path, 'rb') as f:
return f.read()
def reset():
_PROGRESS.update({'stage': 'idle', 'target': None, 'action': None})
def run_main(*argv):
reset()
real_argv = sys.argv
sys.argv = ['mvp_modernize.py'] + list(argv)
try:
return quiet(main)
finally:
sys.argv = real_argv
PE = 0x80
TABLE = 0x800
POOLS = (0x1000, 0x1100)
# 王朝年份兩處(甲、乙)與它們的「第二份」(做重複用)。年份在樣態裡的位置:
# 甲處 cmp/jne/push 共 5 個位元組之後,乙處前 14 個位元組之後。
YEAR_SITES = {'a': 0x1200, 'b': 0x1300, 'a2': 0x1400, 'b2': 0x1500}
YA, YB = YEAR_SITES['a'] + 5, YEAR_SITES['b'] + 14
def year_site_a(year):
return (b'\x3c\x01\x75\x0c\x68' + struct.pack('<H', year) + b'\x00\x00'
+ b'\x8b\xce\xe8\x11\x22\x33\x44')
def year_site_b(year):
return (b'\xc6\x04\x24\x03\xc6\x44\x24\x01\x1a\x66\xc7\x44\x24\x02'
+ struct.pack('<H', year) + b'\x8b\x04\x24\x50\xe8\x11\x22\x33\x44')
# 誘餌:形狀只對了一半的指令(甲處少了前面的 cmp/jne、乙處少了前面的 {3 月、26 日}),
# 年份寫的也是 2005。樣態如果被放寬到只看後半段,真的執行檔上這種長相很常見。
DECOY_A, DECOY_B = 0x1600, 0x1700
def fake_exe(checksum=0, tables=1, pool_mb=64, years=None, dup=None, decoy=False):
"""拼一顆 8 KB 的假執行檔。tables 是解析度表放幾份(0、1、2)。
years 是 (甲處年份, 乙處年份),其中一個給 None 就是不放那一處;預設兩處都沒有。
dup 給 'a' 或 'b' 就多放一份重複的(用來驗「找到兩個」)。
decoy 為真就在別處放兩個長得像、但不是的誘餌(用來驗樣態沒有被放寬)。
"""
b = bytearray(0x2000)
b[0:2] = b'MZ'
struct.pack_into('<I', b, 0x3C, PE)
b[PE:PE + 4] = b'PE\x00\x00'
struct.pack_into('<HH', b, PE + 4, MACHINE_I386, 1) # 架構、節區數
struct.pack_into('<I', b, PE + 8, 1107216000) # 2005-02-01 的時間戳
struct.pack_into('<HH', b, PE + 20, 0xE0, 0x010F) # Optional Header 長度、Characteristics
struct.pack_into('<H', b, PE + 24, 0x10B)
struct.pack_into('<I', b, PE + 24 + 64, checksum)
sec = PE + 24 + 0xE0
b[sec:sec + 8] = b'.text\x00\x00\x00'
struct.pack_into('<II', b, sec + 16, 0x2000 - 0x400, 0x400) # 資料長度、資料位移
rows = [(640, 480), (800, 600), (1024, 768), (1280, 1024)]
for t in range(tables):
base = TABLE + t * 0x100
for k, (w, h) in enumerate(rows):
for j, depth in enumerate((16, 32)):
struct.pack_into('<IIII', b, base + (2 * k + j) * RECORD, w, h, depth, 0)
for off in POOLS:
b[off] = 0x68
struct.pack_into('<I', b, off + 1, pool_mb * 1024 * 1024)
b[off + 5] = 0xE8
b[off + 10:off + 12] = b'\x59\xc3'
if years is not None:
ya, yb = years
if ya is not None:
s = year_site_a(ya)
b[YEAR_SITES['a']:YEAR_SITES['a'] + len(s)] = s
if dup == 'a':
b[YEAR_SITES['a2']:YEAR_SITES['a2'] + len(s)] = s
if yb is not None:
s = year_site_b(yb)
b[YEAR_SITES['b']:YEAR_SITES['b'] + len(s)] = s
if dup == 'b':
b[YEAR_SITES['b2']:YEAR_SITES['b2'] + len(s)] = s
if decoy:
s = b'\x68' + struct.pack('<H', 2005) + b'\x00\x00\x8b\xce\xe8\x11\x22\x33\x44'
b[DECOY_A:DECOY_A + len(s)] = s
s = (b'\x66\xc7\x44\x24\x02' + struct.pack('<H', 2005)
+ b'\x8b\x04\x24\x50\xe8\x11\x22\x33\x44')
b[DECOY_B:DECOY_B + len(s)] = s
return bytes(b)
# ── 時鐘版(--dynasty-year clock)用的假執行檔:三個節區的最小 PE ──
# 為什麼不用 fake_exe:時鐘版要看節區表(虛擬位址 ↔ 檔案位移)、匯入表、甲處前面的三個呼叫、
# 兩支「模式判斷」的內部結構、最後一個節區的空白尾端 —— 那顆 8 KB 的假檔一樣都沒有。
# 排法讓每個節區的檔案位移 == RVA(檔案對齊 = 節區對齊 = 0x1000),ImageBase 0x400000,
# 所以虛擬位址 = 0x400000 + 檔案位移,算起來最直接。
CK_BASE = 0x400000
CK_SINGLE, CK_P13, CK_P1213 = 0x1000, 0x1100, 0x1140 # 單例、模式是 13、模式是 12 或 13
CK_SITE, CK_SITEB = 0x1210, 0x1300 # 甲處(cmp al,1 的位置)、乙處
CK_YA, CK_YB = CK_SITE + 5, CK_SITEB + 14 # 兩處年份那 2 個位元組
CK_TAIL = 0x3000 # 最後一個節區(可執行)起點
CK_IAT = 0x2060
def clock_exe(ya=2005, yb=2005, checksum=0, imports='both', tail_used=0x40, bad=None,
last_exec=True, dup=False, mov_alt=False):
"""拼一顆 16 KB、三個節區的假執行檔,放得下時鐘版要的一切。
imports:'both'(kernel32 匯入 LoadLibraryA 與 GetProcAddress)、'no_getproc'、'none'、
'wrong_dll'(同樣兩個函式,但是從別的 DLL 匯入)。
tail_used:最後一個節區開頭有多少位元組是非 0(其餘是空白尾端)。
bad:('p13' / 'p1213' / 'prefix', 位移) —— 把那支判斷(或甲處前綴)的某一個位元組翻掉,
用來驗結構判斷的每一格都真的在看;mov_alt:甲處前綴的 mov ecx,eax 換成另一種編碼(該放行)。
dup:再放一份甲處(用來驗「找到兩個」)。
"""
b = bytearray(0x4000)
b[0:2] = b'MZ'
struct.pack_into('<I', b, 0x3C, PE)
b[PE:PE + 4] = b'PE\x00\x00'
struct.pack_into('<HH', b, PE + 4, MACHINE_I386, 3)
struct.pack_into('<I', b, PE + 8, 1107216000)
struct.pack_into('<HH', b, PE + 20, 0xE0, 0x010F)
struct.pack_into('<H', b, PE + 24, 0x10B)
struct.pack_into('<I', b, PE + 24 + 28, CK_BASE)
struct.pack_into('<I', b, PE + 24 + 64, checksum)
struct.pack_into('<II', b, PE + 24 + 96 + 8, 0x2000, 0x28) # 匯入表的 RVA 與大小
sec = PE + 24 + 0xE0
for k, (nm, va, ch) in enumerate((('.text', 0x1000, 0x60000020), ('.idata', 0x2000, 0xC0000040),
('.cave', 0x3000, 0xE0000020 if last_exec else 0xC0000040))):
o = sec + 40 * k
b[o:o + 8] = nm.encode().ljust(8, b'\0')
struct.pack_into('<IIII', b, o + 8, 0x1000, va, 0x1000, va) # 虛擬大小、RVA、檔案內大小、檔案位移
struct.pack_into('<I', b, o + 36, ch)
b[CK_SINGLE] = 0xC3
# 模式是 13 / 模式是 12 或 13:只有 apply 要認的那幾個位元組是真的,其他隨便
p13 = bytearray(0x38)
p13[0] = 0xE8
p13[5:12] = b'\x80\xb8\xb2\x00\x00\x00\x01'
p13[0x1f:0x25] = b'\x83\xf8\x0d\x0f\x94\xc0'
p13[0x31:0x37] = b'\x83\xf8\x0d\x0f\x94\xc0'
if bad and bad[0] == 'p13':
p13[bad[1]] ^= 0xFF
b[CK_P13:CK_P13 + 0x38] = p13
p1213 = bytearray(0x22)
p1213[0] = 0xE8
p1213[5:12] = b'\x80\xb8\xb2\x00\x00\x00\x01'
p1213[0x1f:0x22] = b'\x83\xf8\x0c'
if bad and bad[0] == 'p1213':
p1213[bad[1]] ^= 0xFF
b[CK_P1213:CK_P1213 + 0x22] = p1213
def site_bytes(at, year):
"""甲處前綴(call 單例 / mov ecx,eax / call 判斷)+ 樣態本體。"""
s = bytearray()
s += b'\xE8' + struct.pack('<i', CK_SINGLE - (at - 12 + 5))
s += b'\x89\xc1'
s += b'\xE8' + struct.pack('<i', CK_P1213 - (at - 5 + 5))
if bad and bad[0] == 'prefix':
s[bad[1]] ^= 0xFF
if mov_alt:
s[5:7] = b'\x8b\xc8'
s += year_site_a(year)
return bytes(s)
if ya is not None:
s = site_bytes(CK_SITE, ya)
b[CK_SITE - 12:CK_SITE - 12 + len(s)] = s
if dup:
s2 = site_bytes(0x1500, ya)
b[0x1500 - 12:0x1500 - 12 + len(s2)] = s2
if yb is not None:
s = year_site_b(yb)
b[CK_SITEB:CK_SITEB + len(s)] = s
# 匯入表:一個 kernel32 的描述子 + 結尾的全 0 描述子
struct.pack_into('<IIIII', b, 0x2000, 0x2040, 0, 0, 0x2080, CK_IAT)
b[0x2080:0x2080 + 13] = (b'USER32.dll\0' if imports == 'wrong_dll' else b'KERNEL32.dll\0')
names = [(b'\0\0LoadLibraryA\0', 0x20A0), (b'\0\0GetProcAddress\0', 0x20C0)]
if imports == 'no_getproc':
names = names[:1]
if imports == 'none':
names = []
for k, (hn, at) in enumerate(names):
b[at:at + len(hn)] = hn
struct.pack_into('<I', b, 0x2040 + 4 * k, at)
struct.pack_into('<I', b, CK_IAT + 4 * k, at)
b[CK_TAIL:CK_TAIL + tail_used] = b'\xAA' * tail_used
return bytes(b)
root = tempfile.mkdtemp(prefix='mvp_modernize_selftest-')
try:
folder = os.path.join(root, 'game')
outside = os.path.join(root, 'outside')
os.makedirs(folder)
os.makedirs(outside)
exe = os.path.join(folder, 'mvp2005.exe')
bak = exe + BACKUP_SUFFIX
ORIGINAL = fake_exe()
def fresh(data=ORIGINAL):
reset()
for x in os.listdir(folder):
os.remove(os.path.join(folder, x))
put(exe, data)
def junk():
return [x for x in os.listdir(folder) if x.startswith('.mvp2005.exe')]
print('\n一、認不得的檔')
check(isinstance(quiet(pe_offsets, ORIGINAL)[0], tuple), '陰性對照:拼出來的假執行檔讀得出 PE 檔頭')
check(stops(pe_offsets, b'ZM' + ORIGINAL[2:]), '開頭不是 MZ → 停下來', True)
check(stops(pe_offsets, ORIGINAL[:PE] + b'XX' + ORIGINAL[PE + 2:]), '找不到 PE 簽章 → 停下來', True)
check(stops(pe_offsets, ORIGINAL[:PE + 40]), '檔頭讀到一半就沒了(讀不到 CheckSum)→ 停下來', True)
gone = os.path.join(folder, 'no-such.exe')
rc, out = run_main(gone)
# 比「找不到 <那個路徑>」整句:拿掉這道守門的話,open() 會丟作業系統的錯誤,
# main() 一樣回 2,只是講的是另一句話 —— 只看結束碼分不出來。
check(rc == 2 and ('找不到 %s' % gone) in out, '路徑不存在 → 停下來,說找不到哪一個檔', True)
print('\n二、用內容找東西')
check(quiet(find_table, ORIGINAL)[0] == TABLE, '陰性對照:解析度表找得到,而且在它真的在的地方')
check(stops(find_table, fake_exe(tables=0)), '找不到解析度表 → 停下來,不猜位址', True)
check(stops(find_table, fake_exe(tables=2)), '找到兩張解析度表 → 停下來,不挑第一個湊答案', True)
check([v for _o, v in find_pool(ORIGINAL)] == [64 << 20, 64 << 20],
'陰性對照:兩處 64 MB 的記憶體池都找得到')
check(find_pool(fake_exe(pool_mb=32)) == [], '32 MB(比 64 MB 小)不認', True)
odd = bytearray(ORIGINAL)
for off in POOLS:
struct.pack_into('<I', odd, off + 1, (64 << 20) + 1)
check(find_pool(bytes(odd)) == [], '不是 1 MB 整數倍的數字不認(只是長得像指令而已)', True)
no_pool = fake_exe(pool_mb=32)
fresh(no_pool)
rc, out = run_main(exe, '--pool', '128', '--apply')
check(rc == 2 and '找不到記憶體池' in out and get(exe) == no_pool and not os.path.lexists(bak),
'找不到記憶體池的檔(例如有光碟保護的原版)給 --pool → 停下來,不寫也不備份', True)
print('\n三、參數')
for label, argv, said in (
('--pool 63(小於 64)', ('--pool', '63'), '64 到 1024'),
('--pool 1025(大於 1024)', ('--pool', '1025'), '64 到 1024'),
('--pool 0 不可以安靜地掉回唯讀體檢', ('--pool', '0'), '64 到 1024'),
('--resolution 100x100(小於 640x480)', ('--resolution', '100x100'), '超出合理範圍'),
('--resolution 1920*1080(寫法不對)', ('--resolution', '1920*1080'), '寬x高'),
('--timestamp 1969-12-31(早於 1970)', ('--timestamp', '1969-12-31'), '無號 32 位元'),
('--timestamp 2106-02-08(晚於 2106-02-07)', ('--timestamp', '2106-02-08'), '無號 32 位元'),
('--timestamp 2026/08/29(寫法不對)', ('--timestamp', '2026/08/29'), 'YYYY-MM-DD')):
fresh()
rc, out = run_main(exe, *argv)
check(rc == 2 and said in out and get(exe) == ORIGINAL and not os.path.lexists(bak),
label + ' → 停下來,回 2', True)
fresh()
rc, out = run_main(exe)
check(rc == 0 and '唯讀' in out and get(exe) == ORIGINAL,
'陰性對照:什麼旋鈕都不加 → 唯讀體檢,回 0')
print('\n四、預覽與 --apply')
fresh()
rc, out = run_main(exe, '--4gb', '--pool', '128', '--resolution', '1920x1080')
check(rc == 0 and get(exe) == ORIGINAL and os.listdir(folder) == ['mvp2005.exe'],
'陰性對照:沒加 --apply 一個位元組都不寫、不留備份')
rc, out = run_main(exe, '--4gb', '--pool', '128', '--resolution', '1920x1080', '--apply')
new = get(exe)
allowed = set()
for off, ln in edit_regions(ORIGINAL):
allowed.update(range(off, off + ln))
diff = [i for i in range(len(new)) if new[i] != ORIGINAL[i]]
ch = struct.unpack_from('<H', new, PE + 22)[0]
check(rc == 0 and len(new) == len(ORIGINAL) and diff and set(diff) <= allowed
and ch & LARGE_ADDRESS_AWARE and find_pool(new)[0][1] == 128 << 20
and read_table(new, TABLE)[0][:2] == (1920, 1080),
'陰性對照:--apply 只動該動的那幾格,大小不變,三件事都改到了')
check(get(bak) == ORIGINAL, '陰性對照:備份逐位元組等於動手之前的執行檔')
check(struct.unpack_from('<I', new, PE + 24 + 64)[0] == 0,
'陰性對照:CheckSum 原本是 0(沒填)就維持 0')
rc, out = run_main(exe, '--4gb', '--pool', '128', '--apply')
check(rc == 0 and get(exe) == new and '沒有事情要做' in out,
'陰性對照:同一件事再做一次 → 沒有事情要做,不會再寫一次')
fresh(fake_exe(checksum=0x1234))
rc, out = run_main(exe, '--4gb', '--apply')
got = get(exe)
ck_off = PE + 24 + 64
check(rc == 0 and struct.unpack_from('<I', got, ck_off)[0] == pe_checksum(got, ck_off) != 0x1234,
'CheckSum 原本有填 → 改完跟著重算(不會留著舊的那個值)', True)
print('\n五、找不到解析度表')
fresh(fake_exe(tables=0))
rc, out = run_main(exe, '--resolution', '1920x1080', '--apply')
check(rc == 2 and get(exe) == fake_exe(tables=0), '找不到解析度表時 --resolution 停下來', True)
fresh(fake_exe(tables=0))
rc, out = run_main(exe, '--4gb', '--apply')
check(rc == 0 and struct.unpack_from('<H', get(exe), PE + 22)[0] & LARGE_ADDRESS_AWARE,
'陰性對照:同一顆檔,--4gb 照樣做得到')
print('\n六、別顆執行檔的備份')
other = bytearray(ORIGINAL)
other[0x1800] = 0x77 # 落在這支會動的那幾格以外
fresh()
put(bak, bytes(other))
rc, out = run_main(exe, '--4gb', '--apply')
check(rc == 2 and '對不上' in out and get(exe) == ORIGINAL,
'旁邊的備份是別顆執行檔的 → --apply 停下來,不在沒有正確備份時動手', True)
rc, out = run_main(exe, '--restore')
check(rc == 2 and '對不上' in out and get(exe) == ORIGINAL,
'同一份別顆的備份 → --restore 也停下來,不蓋掉現在這一顆', True)
print('\n七、換檔那一步失敗')
real_replace = os.replace
def boom_on_exe(src, dst):
if dst == exe:
raise OSError(28, '(自我測試的替身)換檔失敗')
return real_replace(src, dst)
fresh()
os.replace = boom_on_exe
try:
rc, out = run_main(exe, '--4gb', '--apply')
finally:
os.replace = real_replace
check(rc == 2 and get(exe) == ORIGINAL and not junk() and _PROGRESS['stage'] == 'idle'
and '可能已經被換掉' not in out,
'換檔失敗 → 回 2、執行檔原封不動、不留暫存檔,也不嚇人說「可能已經被換掉」', True)
print('\n八、Ctrl-C')
def stop_now(*a, **k):
raise KeyboardInterrupt
fresh()
real_mk = _mkstemp_beside
g = globals()
g['_mkstemp_beside'] = stop_now
try:
rc, out = run_main(exe, '--4gb', '--apply')
finally:
g['_mkstemp_beside'] = real_mk
check(rc == 130 and '一個位元組都沒有動到' in out and get(exe) == ORIGINAL,
'陰性對照:還沒換檔就按 Ctrl-C → 130、說沒動到,而且真的沒動')
if hasattr(signal, 'raise_signal') and threading.current_thread() is threading.main_thread():
def replace_then_sigint(src, dst):
real_replace(src, dst)
if dst == exe:
signal.raise_signal(signal.SIGINT) # 真的訊號,不是自己丟例外
fresh()
os.replace = replace_then_sigint
try:
rc, out = run_main(exe, '--4gb', '--apply')
finally:
os.replace = real_replace
check(rc == 130 and '已經換好了' in out and '--restore' in out
and '一個位元組都沒有動到' not in out and get(exe) != ORIGINAL,
'換好之後才收到真的 SIGINT → 說已經換好了並給還原指令', True)
# 同一件事換到還原那條路上:要說「還原那一步已經做完了」,
# 不可以再叫人跑一次 --restore,也不可以說什麼都沒動到。
fresh()
run_main(exe, '--4gb', '--apply')
os.replace = replace_then_sigint
try:
rc, out = run_main(exe, '--restore')
finally:
os.replace = real_replace
check(rc == 130 and '還原那一步在這之前就已經做完了' in out and '--restore' not in out
and '一個位元組都沒有動到' not in out and get(exe) == ORIGINAL,
'還原換好之後才收到真的 SIGINT → 說還原已經做完了,不叫人再還原一次', True)
else:
skipped.append('真的 SIGINT 那兩道(這個 Python 沒有 signal.raise_signal,或不是在主執行緒)')
print('\n九、還原')
fresh()
run_main(exe, '--4gb', '--apply')
rc, out = run_main(exe, '--restore')
check(rc == 0 and get(exe) == ORIGINAL, '陰性對照:正常還原逐位元組回到原本的樣子')
os.remove(exe)
rc, out = run_main(exe, '--restore')
check(rc == 0 and get(exe) == ORIGINAL, '陰性對照:執行檔被刪掉了也還原得回來')
fresh()
rc, out = run_main(exe, '--restore')
check(rc == 2 and '找不到備份' in out, '找不到備份 → 停下來', True)
fresh()
put(bak, b'JUNK' * 100)
rc, out = run_main(exe, '--restore')
check(rc == 2 and '不是 MZ' in out and get(exe) == ORIGINAL, '備份開頭不是 MZ → 停下來', True)
fresh()
os.remove(exe)
put(bak, ORIGINAL[:0x2000 // 8])
rc, out = run_main(exe, '--restore')
check(isinstance(rc, SystemExit) and '壞的' in str(rc) and not os.path.lexists(exe),
'正本不見了、備份又是半截的(只剩八分之一)→ 不拿它還原', True)
# 下面兩個也是「正本不見了」(正本還在的話,前面那道「對不對得上」會先擋),
# 而且比的是那一道自己講的話:拿掉「節區表被截斷」那一道的話,後面「讀不出
# 執行檔的結構」那一道照樣會擋,只看「有沒有停下來」分不出是哪一道擋的。
for label, broken, said in (
('備份的 PE 簽章不見了', ORIGINAL[:PE] + b'XX' + ORIGINAL[PE + 2:], 'PE 檔頭不在'),
('備份在節區表中間被截斷', ORIGINAL[:PE + 24 + 0xE0 + 20], '節區表被截斷')):
fresh()
os.remove(exe)
put(bak, broken)
rc, out = run_main(exe, '--restore')
check(isinstance(rc, SystemExit) and said in str(rc) and not os.path.lexists(exe),
'正本不見了、' + label + ' → 不拿它還原', True)
fresh()
run_main(exe, '--4gb', '--apply')
changed = get(exe)
os.replace = boom_on_exe
try:
rc, out = run_main(exe, '--restore')
finally:
os.replace = real_replace
check(rc == 2 and get(exe) == changed and not junk() and _PROGRESS['stage'] == 'idle'
and '可能已經被換掉' not in out,
'還原換檔失敗 → 正本維持還原之前那一份、不留暫存檔,也不嚇人說「可能已經被換掉」', True)
print('\n十、符號連結')
can_link = True
try:
os.symlink(os.path.join(outside, 'x'), os.path.join(root, 'probe-link'))
os.remove(os.path.join(root, 'probe-link'))
except (OSError, NotImplementedError, AttributeError):
can_link = False
if can_link:
real = os.path.join(outside, 'real.exe')
fresh()
os.remove(exe)
put(real, ORIGINAL)
os.symlink(real, exe)
rc, out = run_main(exe, '--4gb', '--apply')
check(isinstance(rc, SystemExit) and '符號連結' in str(rc) and get(real) == ORIGINAL
and os.path.islink(exe),
'執行檔是一條指到資料夾外面的連結 → --apply 停下來,外面那顆不動', True)
rc, out = run_main(exe)
check(rc == 0, '陰性對照:同一條連結,唯讀體檢照樣看得下去')
# 備份放一份「開過 4 GB」的版本:它對得上外面那顆(差的位元組落在會動的那幾格裡),
# 所以守門要是沒擋,還原就會真的把外面那顆改掉,下面那個比對才會亮。
flagged = bytearray(ORIGINAL)
chars = struct.unpack_from('<H', flagged, PE + 22)[0]
struct.pack_into('<H', flagged, PE + 22, chars | LARGE_ADDRESS_AWARE)
put(bak, bytes(flagged))
rc, out = run_main(exe, '--restore')
check(isinstance(rc, SystemExit) and '符號連結' in str(rc) and get(real) == ORIGINAL
and os.path.islink(exe),
'執行檔是一條連結 → --restore 也停下來,不把備份寫到連結指的地方', True)
fresh()
victim = os.path.join(outside, 'victim.bak')
os.symlink(victim, bak)
rc, out = run_main(exe, '--4gb', '--apply')
check(isinstance(rc, SystemExit) and get(exe) == ORIGINAL and not os.path.lexists(victim),
'備份的名字是連結 → --apply 停下來,外面沒有憑空多一個檔', True)
rc, out = run_main(exe, '--restore')
check(isinstance(rc, SystemExit) and get(exe) == ORIGINAL,
'備份是連結 → --restore 也停下來', True)
else:
skipped.append('符號連結那 5 道(這台機器做不出符號連結)')
print('\n十一、寫檔途中與寫完之後')
# 這一段的替身都只在「那一個動作」上動手腳,其他照真的跑。
real_cfo = shutil.copyfileobj
real_copymode = shutil.copymode
real_fsync = os.fsync
def half_copy(fsrc, fdst, *a, **k):
data = fsrc.read()
fdst.write(data[:len(data) // 2])
fresh()
shutil.copyfileobj = half_copy
try:
rc, out = run_main(exe, '--4gb', '--apply')
finally:
shutil.copyfileobj = real_cfo
check(rc == 2 and '只複製了一部分' in out and get(exe) == ORIGINAL
and not os.path.lexists(bak) and not junk(),
'備份只複製了一半(磁碟滿之類)→ 不當成備份、執行檔不動、不留暫存檔', True)
def replace_then_grow(src, dst):
real_replace(src, dst)
if dst == exe:
with open(exe, 'ab') as f:
f.write(b'\x00')
def replace_then_flip(src, dst):
real_replace(src, dst)
if dst == exe:
with open(exe, 'r+b') as f:
f.seek(0x1800) # 落在這支會動的那幾格以外
f.write(b'\x77')
for label, fake, said in (
('寫進去之後讀回來,檔案大小變了', replace_then_grow, '寫入後檔案大小變了'),
('寫進去之後讀回來,多動了一個不該動的位元組', replace_then_flip,
'實際改動的位元組跟預期不符')):
fresh()
os.replace = fake
try:
rc, out = run_main(exe, '--4gb', '--apply')
finally:
os.replace = real_replace
check(rc == 2 and said in out and '--restore' in out,
label + ' → 回 2,要你立刻還原,並給還原指令', True)
def no_chmod(*a, **k):
raise OSError(1, '(自我測試的替身)這個檔案系統不讓你設權限')
fresh()
shutil.copymode = no_chmod
try:
rc, out = run_main(exe, '--4gb', '--apply')
finally:
shutil.copymode = real_copymode
check(rc == 0 and '不讓我設定權限' in out
and struct.unpack_from('<H', get(exe), PE + 22)[0] & LARGE_ADDRESS_AWARE,
'陰性對照:設不了權限的檔案系統 → 印一行告訴你,照樣把改好的內容寫下去')
def fsync_then_scribble(fd):
real_fsync(fd)
os.lseek(fd, 0x1800, os.SEEK_SET) # 暫存檔已經寫完、落地之後才去改一個位元組
os.write(fd, b'\x77')
fresh()
run_main(exe, '--4gb', '--apply')
changed = get(exe)
os.fsync = fsync_then_scribble # --restore 這條路上只有 _do_copy 會呼叫 fsync
try:
rc, out = run_main(exe, '--restore')
finally:
os.fsync = real_fsync
check(rc == 2 and '寫出來的暫存檔跟備份對不起來' in out and get(exe) == changed and not junk(),
'還原寫出來的暫存檔讀回來跟備份對不起來 → 不換上去、正本維持原樣、不留暫存檔', True)
fresh()
run_main(exe, '--4gb', '--apply')
os.replace = replace_then_flip
try:
rc, out = run_main(exe, '--restore')
finally:
os.replace = real_replace
check(rc == 2 and '還原後內容跟備份不一樣' in out,
'還原換好之後讀回來跟備份不一樣 → 回 2、要你手動檢查,不印成功', True)
print('\n十二、王朝起始年份')
YEXE = fake_exe(years=(2005, 2005))
check(quiet(find_year_sites, YEXE)[0] == ([(YA, 2005)], [(YB, 2005)]),
'陰性對照:兩處年份都找得到,而且在它們真的在的地方')
check(quiet(find_year_sites, ORIGINAL)[0] == ([], []),
'沒有那兩處的檔 → 一個都找不到,不亂認', True)
check({(YA, 2), (YB, 2)} <= set(edit_regions(YEXE)),
'edit_regions 認得年份兩處(不認的話,打過補丁的檔會被當成「別顆執行檔」)', True)
DEXE = fake_exe(years=(2005, 2005), decoy=True)
check(quiet(find_year_sites, DEXE)[0] == ([(YA, 2005)], [(YB, 2005)]),
'長得像、但前面少了一半的誘餌(年份也寫 2005)不認,只認真的那兩處', True)
fresh(DEXE)
rc, out = run_main(exe, '--dynasty-year', '2026', '--apply')
new = get(exe)
check(rc == 0 and [i for i in range(len(new)) if new[i] != DEXE[i]] == [YA, YB],
'陰性對照:旁邊有誘餌的檔照樣改得動,而且只改那兩處,誘餌原封不動')
fresh(YEXE)
rc, out = run_main(exe, '--dynasty-year', '2026')
check(rc == 0 and get(exe) == YEXE and os.listdir(folder) == ['mvp2005.exe']
and '2005 改成 2026' in out,
'陰性對照:沒加 --apply 一個位元組都不寫、不留備份,預覽說得出 2005 改成 2026')
rc, out = run_main(exe, '--dynasty-year', '2026', '--apply')
new = get(exe)
diff = [i for i in range(len(new)) if new[i] != YEXE[i]]
check(rc == 0 and len(new) == len(YEXE) and diff == [YA, YB]
and quiet(find_year_sites, new)[0] == ([(YA, 2026)], [(YB, 2026)]),
'陰性對照:--apply 只動那兩個年份(2005 到 2026 只有低位元組不同),大小不變')
check(get(bak) == YEXE, '陰性對照:備份逐位元組等於動手之前的執行檔')
rc, out = run_main(exe, '--dynasty-year', '2026', '--apply')
check(rc == 0 and get(exe) == new and '本來就是 2026' in out and '沒有事情要做' in out,
'陰性對照:已經是那個年份再做一次 → 沒有事情要做,不會再寫一次')
fresh(fake_exe(checksum=0x1234, years=(2005, 2005)))
rc, out = run_main(exe, '--dynasty-year', '2026', '--apply')
got = get(exe)
check(rc == 0 and struct.unpack_from('<I', got, ck_off)[0] == pe_checksum(got, ck_off) != 0x1234,
'CheckSum 原本有填 → 改年份之後跟著重算', True)
for label, y in (('2101(超過實測過的最大年份)', '2101'),
('2004(早於 2005)', '2004'),
('0 不可以安靜地掉回唯讀體檢', '0')):
fresh(YEXE)
rc, out = run_main(exe, '--dynasty-year', y)
check(rc == 2 and '只接受 2005 到 2100' in out and get(exe) == YEXE
and not os.path.lexists(bak),
'--dynasty-year ' + label + ' → 停下來,回 2', True)
fresh(YEXE)
rc, out = run_main(exe, '--dynasty-year', '2100', '--apply')
check(rc == 0 and quiet(find_year_sites, get(exe))[0] == ([(YA, 2100)], [(YB, 2100)]),
'陰性對照:範圍的邊界 2100 放行')
fresh(YEXE)
rc, out = run_main(exe, '--dynasty-year', '2035', '--apply')
check(rc == 0 and quiet(find_year_sites, get(exe))[0] == ([(YA, 2035)], [(YB, 2035)]),
'陰性對照:以前被當成上限的 2035 現在放行(那道牆是老闆模式的,王朝沒有)')
for y, must in (('2026', ('老闆模式', '2035', '只能打 9 季', '21 年', '約可打 99 季')), ('2034', ('只能打 1 季',)),
('2035', ('退休', '約可打 90 季')),
('2100', ('退休', '95 年', '約可打 25 季'))):
fresh(YEXE)
rc, out = run_main(exe, '--dynasty-year', y, '--apply')
check(rc == 0 and all(t in out for t in must),
'--dynasty-year ' + y + ' → 印出老闆模式的警語(' + '、'.join(must) + ')')
fresh(YEXE)
rc, out = run_main(exe, '--dynasty-year', '2005', '--apply')
check(rc == 0 and '本來就是 2005' in out and get(exe) == YEXE,
'陰性對照:範圍的另一個邊界 2005 放行(而且這顆本來就是 2005,不用做)')
for label, data, said in (
('一處都找不到(例如有光碟保護的原版)', ORIGINAL,
'設年份那處找到 0 個、年齡基準日那處找到 0 個'),
('只找到設年份那一處', fake_exe(years=(2005, None)),
'設年份那處找到 1 個、年齡基準日那處找到 0 個'),
('只找到年齡基準日那一處', fake_exe(years=(None, 2005)),
'設年份那處找到 0 個、年齡基準日那處找到 1 個'),
('設年份那處找到兩個', fake_exe(years=(2005, 2005), dup='a'),
'設年份那處找到 2 個、年齡基準日那處找到 1 個'),
('年齡基準日那處找到兩個', fake_exe(years=(2005, 2005), dup='b'),
'設年份那處找到 1 個、年齡基準日那處找到 2 個')):
fresh(data)
rc, out = run_main(exe, '--dynasty-year', '2026', '--apply')
check(rc == 2 and said in out and get(exe) == data and not os.path.lexists(bak),
label + ' → 停下來,不寫也不備份(不會只改半邊)', True)
fresh(ORIGINAL)
rc, out = run_main(exe, '--4gb', '--dynasty-year', '2026', '--apply')
check(rc == 2 and get(exe) == ORIGINAL and not os.path.lexists(bak),
'年份找不到時整行指令停下來,同一行的 --4gb 也不會被套用', True)
fresh(fake_exe(years=(2005, 2026)))
rc, out = run_main(exe)
check(rc == 0 and '兩處不一致' in out and '設年份那處 2005' in out
and '年齡基準日那處 2026' in out,
'兩處年份不一致 → 體檢指出來(有人只改了其中一處)', True)
rc, out = run_main(exe, '--dynasty-year', '2026', '--apply')
got = get(exe)
check(rc == 0 and quiet(find_year_sites, got)[0] == ([(YA, 2026)], [(YB, 2026)])
and '設年份那處 2005、年齡基準日那處 2026' in out
and [i for i in range(len(got)) if got[i] != fake_exe(years=(2005, 2026))[i]] == [YA],
'陰性對照:只有一處不一致 → 補齊那一處,另一處原封不動')
fresh(YEXE)
run_main(exe, '--dynasty-year', '2030', '--apply')
rc, out = run_main(exe, '--dynasty-year', '2020', '--apply')
check(rc == 0 and '備份已存在' in out and '對不上' not in out
and quiet(find_year_sites, get(exe))[0] == ([(YA, 2020)], [(YB, 2020)]),
'陰性對照:打過年份補丁的檔,換一個年份再打一次 → 沿用最早那份備份,不當成「別顆執行檔」')
rc, out = run_main(exe, '--restore')
check(rc == 0 and get(exe) == YEXE, '陰性對照:年份補丁也還原得回原樣')
fresh(YEXE)
rc, out = run_main(exe, '--4gb', '--pool', '128', '--dynasty-year', '2026', '--apply')
new = get(exe)
allowed = set()
for off, ln in edit_regions(YEXE):
allowed.update(range(off, off + ln))
diff = [i for i in range(len(new)) if new[i] != YEXE[i]]
check(rc == 0 and diff and set(diff) <= allowed and YA in diff and YB in diff
and struct.unpack_from('<H', new, PE + 22)[0] & LARGE_ADDRESS_AWARE
and find_pool(new)[0][1] == 128 << 20
and quiet(find_year_sites, new)[0] == ([(YA, 2026)], [(YB, 2026)]),
'陰性對照:年份跟其他旋鈕同一行一起用,全部做到,改動都落在認得的格子裡')
print('\n十三、檢查碼原樣保留(--keep-checksum)')
ODD_CK = 0x13579BDF # 任意非零值,只用來測檢查碼原樣保留
fresh(fake_exe(checksum=ODD_CK))
rc, out = run_main(exe, '--4gb', '--apply')
got = get(exe)
check(rc == 0 and struct.unpack_from('<I', got, ck_off)[0] == pe_checksum(got, ck_off) != ODD_CK,
'陰性對照:沒加 --keep-checksum,原本有填的校驗值照舊重算(預設行為沒變)')
# main() 每次都會明講 keep_checksum,所以上面那道只驗得到「命令列沒給 → 傳 False」。
# 函式自己的預設值要直接呼叫才看得到(別的程式 import 這支腳本來用的時候吃的就是它)。
fresh(fake_exe(checksum=ODD_CK, years=(2005, 2005)))
reset()
quiet(apply_changes, exe, False, None, None, None, True, 2026)
got = get(exe)
check(struct.unpack_from('<I', got, ck_off)[0] == pe_checksum(got, ck_off) != ODD_CK,
'直接呼叫 apply_changes、不給 keep_checksum → 預設仍是重算校驗值', True)
fresh(fake_exe(checksum=ODD_CK))
before = get(exe)
rc, out = run_main(exe, '--4gb', '--keep-checksum')
check(rc == 0 and get(exe) == before and '原樣保留' in out,
'預覽:加了 --keep-checksum 會說校驗值原樣保留,而且一個位元組都不寫')
rc, out = run_main(exe, '--4gb', '--keep-checksum', '--apply')
got = get(exe)
check(rc == 0 and struct.unpack_from('<I', got, ck_off)[0] == ODD_CK
and [i for i in range(len(got)) if got[i] != before[i]] == [PE + 22],
'加了 --keep-checksum → 校驗值原封不動,改動只剩大位址旗標那 1 個位元組', True)
fresh(fake_exe(checksum=ODD_CK, years=(2005, 2005)))
before = get(exe)
rc, out = run_main(exe, '--dynasty-year', '2026', '--keep-checksum', '--apply')
got = get(exe)
check(rc == 0 and [i for i in range(len(got)) if got[i] != before[i]] == [YA, YB]
and struct.unpack_from('<I', got, ck_off)[0] == ODD_CK,
'年份加 --keep-checksum → 整個檔只動那兩個年份,校驗值原封不動', True)
rc, out = run_main(exe, '--restore')
check(rc == 0 and get(exe) == before, '陰性對照:保留校驗值改過的檔,也還原得回原樣')
for label, argv in (('預覽', ('--keep-checksum',)), ('加 --apply', ('--keep-checksum', '--apply'))):
fresh()
rc, out = run_main(exe, *argv)
check(rc == 2 and '要搭配別的旋鈕' in out and get(exe) == ORIGINAL
and not os.path.lexists(bak),
'單獨給 --keep-checksum(' + label + ')→ 停下來,不安靜地掉回唯讀體檢', True)
print('\n十四、時鐘年份(--dynasty-year clock)')
# 金樣本:Windows 實測通過(2026-10-29,三輪)那顆執行檔裡的洞穴,133 個位元組。
# 位址是那顆檔的:洞穴 0x964A20、單例 0x629F70、模式是 13 0x62A0A0、甲處返回點 0x5D90D8、
# LoadLibraryA 的 IAT 0x873210、GetProcAddress 的 IAT 0x8731DC。
GOLDEN = bytes.fromhex('58e84a55ccff89c1e87356ccff3c01750a68d5070000e99d46c7ff688b4a9600ff151032870068984a960050ff15dc31870085c0742b83ec1054ffd00fb7042483c4103dd50700007d05b8d50700003d340800007e05b83408000050e95746c7ff68d5070000e94d46c7ff6b65726e656c33322e646c6c004765744c6f63616c54696d6500')
check(quiet(clock_cave_code, 0x964A20, 0x629F70, 0x62A0A0, 0x5D90D8, 0x873210, 0x8731DC)[0] == GOLDEN,
'洞穴的機器碼跟 Windows 實測通過的那一份逐位元組相同(133 個位元組)', True)
CKX = clock_exe()
ck_regions = [(CK_SITE + 4, 5), (CK_TAIL + 0x50, CLOCK_CAVE_LEN)]
plan = quiet(clock_plan, CKX)[0]
check(isinstance(plan, dict) and plan['cave_off'] == CK_TAIL + 0x50
and plan['cave_va'] == CK_BASE + CK_TAIL + 0x50 and plan['push_off'] == CK_SITE + 4
and plan['back_va'] == CK_BASE + CK_SITE + 4 + 5 and len(plan['code']) == CLOCK_CAVE_LEN
and plan['load'] == CK_BASE + CK_IAT and plan['getproc'] == CK_BASE + CK_IAT + 4,
'陰性對照:假的執行檔算得出施工圖,洞穴在最後一個節區已用部分之後(空一格、對齊 16),'
'位址都從檔案自己推出來')
check(quiet(clock_state, CKX)[0] == (None, None), '沒打過時鐘的檔 → 甲處不是 call,不認成時鐘版')
check(set(clock_regions(CKX)) == set(ck_regions) and set(ck_regions) <= set(edit_regions(CKX)),
'edit_regions 認得甲處與洞穴(不認的話,打過時鐘的檔會被當成「別顆執行檔」)', True)
fresh(CKX)
rc, out = run_main(exe, '--dynasty-year', 'clock')
check(rc == 0 and get(exe) == CKX and os.listdir(folder) == ['mvp2005.exe'] and '跟著電腦時鐘' in out
and '洞穴寫入 133 個位元組' in out and '老闆模式' in out and 'Windows' in out
and '沒有實機測過' in out,
'陰性對照:沒加 --apply 一個位元組都不寫、不留備份,預覽說得出要做什麼')
rc, out = run_main(exe)
check(rc == 0 and '--dynasty-year clock' in out and '王朝起始年份 2005' in out,
'體檢:沒打過時鐘的檔 → 列出 clock 這個選項,而且王朝起始年份照原樣顯示')
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
clocked = get(exe)
diff = [i for i in range(len(clocked)) if clocked[i] != CKX[i]]
allowed = set()
for off, ln in ck_regions:
allowed.update(range(off, off + ln))
check(rc == 0 and len(clocked) == len(CKX) and diff and set(diff) <= allowed
and clocked[CK_SITE + 4] == 0xE8
and clocked[CK_TAIL + 0x50:CK_TAIL + 0x50 + CLOCK_CAVE_LEN] == plan['code']
and clocked[CK_YB:CK_YB + 2] == CKX[CK_YB:CK_YB + 2],
'陰性對照:--apply 只動甲處 5 個位元組與洞穴,乙處與其他地方原封不動,大小不變')
check(get(bak) == CKX, '陰性對照:備份逐位元組等於動手之前的執行檔')
st, info = quiet(clock_state, clocked)[0]
check(st == 'clock' and info['cave_off'] == plan['cave_off']
and set(clock_regions(clocked)) == set(ck_regions),
'打過之後認得出是時鐘版,而且算出的格子跟打之前相同(--restore 才不會把它當成別顆)', True)
rc, out = run_main(exe)
check(rc == 0 and '跟著電腦時鐘走' in out and '✅' in out, '體檢:打過時鐘的檔會說「跟著電腦時鐘走」')
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
check(rc == 0 and get(exe) == clocked and '本來就是跟著電腦時鐘走' in out and '沒有事情要做' in out,
'陰性對照:已經是時鐘版再做一次 → 沒有事情要做,不會再寫一次')
rc, out = run_main(exe, '--restore')
check(rc == 0 and get(exe) == CKX, '陰性對照:時鐘版也還原得回原樣(逐位元組)')
# 時鐘 ↔ 寫死:兩條路走到同一個結果,逐位元組比
fresh(CKX)
run_main(exe, '--dynasty-year', '2026', '--apply')
fixed_direct = get(exe)
fresh(CKX)
run_main(exe, '--dynasty-year', 'clock', '--apply')
rc, out = run_main(exe, '--dynasty-year', '2026', '--apply')
check(rc == 0 and get(exe) == fixed_direct and '備份已存在' in out and '對不上' not in out
and quiet(find_year_sites, get(exe))[0] == ([(CK_YA, 2026)], [(CK_YB, 2026)]),
'時鐘版改成寫死 2026 → 跟直接從原版改成 2026 逐位元組相同(洞穴清乾淨、甲處換回 push)', True)
fresh(CKX)
run_main(exe, '--dynasty-year', 'clock', '--apply')
run_main(exe, '--dynasty-year', '2005', '--apply')
check(get(exe) == CKX, '時鐘版改成寫死 2005 → 整顆檔回到原版(逐位元組)', True)
fresh(CKX)
run_main(exe, '--dynasty-year', '2026', '--apply')
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
check(rc == 0 and get(exe) == clocked and '年齡基準日從 2026 改回 2005' in out,
'寫死 2026 改成時鐘版 → 跟直接從原版改成時鐘版逐位元組相同(乙處也被改回 2005)', True)
# 不是本工具做的洞穴 → 不碰
foreign = bytearray(clocked)
foreign[CK_TAIL + 0x50 + 10] ^= 1
foreign = bytes(foreign)
check(quiet(clock_state, foreign)[0][0] == 'foreign', '洞穴被改了一個位元組 → 認成「不是本工具做的」', True)
for label, argv in (('改成時鐘版', ('--dynasty-year', 'clock', '--apply')),
('改成 2026', ('--dynasty-year', '2026', '--apply'))):
fresh(foreign)
rc, out = run_main(exe, *argv)
check(rc == 2 and '不是本工具做的' in out and get(exe) == foreign and not os.path.lexists(bak),
'洞穴不是本工具做的 → ' + label + ',停下來,不寫也不備份', True)
fresh(foreign)
rc, out = run_main(exe)
check(rc == 0 and '不是本工具做的' in out, '體檢:洞穴不是本工具做的 → 指出來,不說成時鐘版')
# 時鐘版的前提缺一個就停下來
for label, data, said in (
('最後一個節區尾端放不下洞穴', clock_exe(tail_used=0xF61), '放不下洞穴'),
('最後一個節區不可執行', clock_exe(last_exec=False), '不可執行'),
('kernel32 沒有匯入 GetProcAddress', clock_exe(imports='no_getproc'), 'GetProcAddress'),
('沒有匯入 LoadLibraryA 與 GetProcAddress', clock_exe(imports='none'), 'LoadLibraryA'),
('同樣兩個函式但不是從 kernel32 匯入的', clock_exe(imports='wrong_dll'), 'LoadLibraryA'),
('「模式是 13」那支開頭不是 call', clock_exe(bad=('p13', 0)), '模式是 13'),
('「模式是 13」那支的比較指令對不上', clock_exe(bad=('p13', 5)), '模式是 13'),
('「模式是 13」那支第一個 cmp eax,0xd 對不上', clock_exe(bad=('p13', 0x1f + 2)), '模式是 13'),
('「模式是 13」那支第二個 cmp eax,0xd 對不上', clock_exe(bad=('p13', 0x31 + 2)), '模式是 13'),
('「模式是 12 或 13」那支開頭不是 call', clock_exe(bad=('p1213', 0)), '模式是 12 或 13'),
('「模式是 12 或 13」那支的比較指令對不上', clock_exe(bad=('p1213', 5)), '模式是 12 或 13'),
('「模式是 12 或 13」那支的 cmp eax,0xc 對不上', clock_exe(bad=('p1213', 0x1f + 2)), '模式是 12 或 13'),
('甲處前面第一個 call 不是 call', clock_exe(bad=('prefix', 0)), '甲處前面'),
('甲處前面的 mov ecx,eax 對不上', clock_exe(bad=('prefix', 5)), '甲處前面'),
('甲處前面第二個 call 不是 call', clock_exe(bad=('prefix', 7)), '甲處前面'),
('甲處找到兩個', clock_exe(dup=True), '設年份那處找到 2 個'),
('找不到甲處', clock_exe(ya=None), '設年份那處找到 0 個'),
('找不到乙處(年齡基準日)', clock_exe(yb=None), '年齡基準日那處找到 0 個')):
fresh(data)
rc, out = run_main(exe, '--4gb', '--dynasty-year', 'clock', '--apply')
check(rc == 2 and said in out and get(exe) == data and not os.path.lexists(bak),
label + ' → 停下來,同一行的 --4gb 也不套用,不寫也不備份', True)
fresh(clock_exe(tail_used=0xF60))
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
check(rc == 0 and quiet(clock_state, get(exe))[0][0] == 'clock',
'陰性對照:尾端剛好放得下(差一個位元組就放不下)→ 放行')
fresh(clock_exe(mov_alt=True))
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
check(rc == 0 and quiet(clock_state, get(exe))[0][0] == 'clock',
'陰性對照:甲處前面的 mov ecx,eax 是另一種編碼(8b c8)→ 放行')
pe32p = bytearray(CKX)
struct.pack_into('<H', pe32p, PE + 24, 0x20B)
fresh(bytes(pe32p))
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
check(rc == 2 and '0x10B' in out and get(exe) == bytes(pe32p) and not os.path.lexists(bak),
'不是 32 位元的 PE(魔數 0x20B)→ 停下來,不寫也不備份', True)
no_secs = bytearray(CKX)
struct.pack_into('<H', no_secs, PE + 6, 0)
fresh(bytes(no_secs))
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
check(rc == 2 and '節區數' in out and get(exe) == bytes(no_secs) and not os.path.lexists(bak),
'節區數是 0 → 停下來,不寫也不備份', True)
secs_demo = [(0x10, 0x1000, 0x100, 0x400, 0), (0, 0x2000, 0x80, 0x600, 0)]
check(quiet(_rva_off, secs_demo, 0x1008, 0x10000)[0] == 0x408
and quiet(_rva_off, secs_demo, 0x2040, 0x10000)[0] == 0x640
and stops(_rva_off, secs_demo, 0x1080, 0x10000) and stops(_rva_off, secs_demo, 0x2080, 0x10000)
and stops(_rva_off, secs_demo, 0x2040, 0x600),
'位址換算:只認「載入後有、檔案裡也有」的那一段(取兩者較小),落在外面或超出檔案就停下來', True)
mixed = bytearray(clocked)
mixed[0x14F4:0x14F4 + 28] = clock_exe(dup=True)[0x14F4:0x14F4 + 28]
check(quiet(clock_state, bytes(mixed))[0][0] == 'foreign',
'同一顆檔裡既有「呼叫」又有「push 年份」的甲處 → 認成不確定,不碰', True)
odd_b = bytearray(clocked)
struct.pack_into('<H', odd_b, CK_YB, 2026)
fresh(bytes(odd_b))
rc, out = run_main(exe)
check(rc == 0 and '年齡基準日(乙處)是 2026' in out, '體檢:時鐘版但乙處不是 2005 → 指出來')
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
check(rc == 0 and get(exe) == clocked and '年齡基準日(乙處)從 2026 改回 2005' in out,
'時鐘版但乙處被改過 → 只把乙處補回 2005,其餘原封不動', True)
# 跟別的旋鈕、--keep-checksum 一起用
fresh(CKX)
rc, out = run_main(exe, '--4gb', '--dynasty-year', 'clock', '--apply')
new = get(exe)
diff = [i for i in range(len(new)) if new[i] != CKX[i]]
allowed = set()
for off, ln in edit_regions(CKX):
allowed.update(range(off, off + ln))
check(rc == 0 and set(diff) <= allowed and PE + 22 in diff
and struct.unpack_from('<H', new, PE + 22)[0] & LARGE_ADDRESS_AWARE
and quiet(clock_state, new)[0][0] == 'clock',
'陰性對照:時鐘版跟 --4gb 同一行一起用,全部做到,改動都落在認得的格子裡')
fresh(clock_exe(checksum=ODD_CK))
before = get(exe)
rc, out = run_main(exe, '--dynasty-year', 'clock', '--keep-checksum', '--apply')
got = get(exe)
check(rc == 0 and struct.unpack_from('<I', got, ck_off)[0] == ODD_CK
and set(i for i in range(len(got)) if got[i] != before[i]) <= allowed,
'時鐘版加 --keep-checksum → 校驗值原封不動,改動只在甲處與洞穴', True)
fresh(clock_exe(checksum=ODD_CK))
rc, out = run_main(exe, '--dynasty-year', 'clock', '--apply')
got = get(exe)
check(rc == 0 and struct.unpack_from('<I', got, ck_off)[0] == pe_checksum(got, ck_off) != ODD_CK,
'陰性對照:沒加 --keep-checksum,原本有填的校驗值照舊重算')
# 備份對照:洞穴那一段不同是正常的,別處不同不是
check(backup_mismatch(CKX, clocked) is None, '備份(原版)對現在(時鐘版):不同的位元組全落在認得的格子裡 → 對得起來')
stray = bytearray(clocked)
stray[0x1400] ^= 1
check(backup_mismatch(CKX, bytes(stray)) is not None,
'時鐘版上別處多一個不同的位元組 → 備份對不起來,擋下', True)
# 命令列
errbuf = io.StringIO()
fresh(CKX)
with contextlib.redirect_stderr(errbuf):
rc, out = run_main(exe, '--dynasty-year', 'clock2')
check(isinstance(rc, SystemExit) and rc.code == 2 and '要寫年份' in errbuf.getvalue() and get(exe) == CKX,
'--dynasty-year 後面不是年份也不是 clock → 停下來,回 2', True)
rc, out = run_main(exe, '--dynasty-year', 'CLOCK')
check(rc == 0 and '跟著電腦時鐘' in out, '陰性對照:clock 大小寫不拘')
rc, out = run_main(exe, '--dynasty-year', '2101', '--apply')
check(rc == 2 and '只接受 2005 到 2100(或 clock)' in out and get(exe) == CKX,
'超出範圍的訊息也提醒可以寫 clock', True)
print('\n十五、-O 守門')
check(_opt[0], '假裝開了 -O(把 _optimize_level 換成回傳 1),自我測試拒跑、結束碼 2', True)
check(_opt[1], '陰性對照:換回原本那一支之後,沒開 -O 的時候守門放行')
finally:
reset()
shutil.rmtree(root, ignore_errors=True)
print()
for why in skipped:
print(' ⚠️ 跳過(沒有測到,不算通過):%s' % why)
if tally['fail']:
print('自我測試:有 %d 道沒過(共 %d 道)' % (tally['fail'], tally['n']))
return 1
print('自我測試:全部通過(%d 道檢查,其中 %d 道是餌)' % (tally['n'], tally['bait']))
return 0
# ─────────────────────────────────────────────────────────
# 「-O 拒跑」那道守門的餌(2026-09-24 加)
#
# 自我測試一開頭有一道守門:在 python -O 底下拒跑(-O 會把 assert 整段拿掉,
# 測試會變成一片假的綠燈)。這支的 --selftest 跟這道守門是 2026-09-24 一起加的。
# 守門不直接問 sys.flags.optimize:那是唯讀的,同一個程序裡沒辦法把 -O
# 打開又關掉,直接問它的話這道守門就下不了餌 —— 哪天被拆掉,自我測試照樣全綠。
# 所以守門問的是 _optimize_level(),自我測試可以暫時把它換掉來下餌。
# ─────────────────────────────────────────────────────────
def _optimize_level():
"""python 的 -O 等級:沒加 -O 是 0,加 -O 是 1,加 -OO 是 2。"""
return sys.flags.optimize
class _OptGuardPassed(Exception):
"""_optimize_bait() 用的記號:再叫一次自我測試時,守門放行、走到了下一行。"""
def _optimize_bait(selftest_fn):
"""-O 守門的餌與陰性對照。回傳 (餌被擋下來了, 陰性對照被放行了)。
自我測試在守門的下一行就叫這一支,這一支再叫兩次 selftest_fn(輸出收起來不印):
· 餌:先把 _optimize_level 換成「回傳 1」(假裝開了 -O)。那一次必須在守門
那裡就停下來 —— 結束碼 2,而且印出來的那句話提到 -O。
· 陰性對照:換回原本那一支之後再叫一次,守門必須放行。放行之後的下一行
就是這裡,所以那一次會丟出 _OptGuardPassed 立刻收工 ——
不會把整套自我測試再跑一遍,也不會在磁碟上留下任何東西。
守門被拆掉的話,餌那一次也會一路走到這裡、丟出 _OptGuardPassed,就算沒擋下來。
"""
if getattr(_optimize_bait, 'busy', False):
raise _OptGuardPassed()
import contextlib
import io
global _optimize_level
real = _optimize_level
def once():
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
rc = selftest_fn()
except SystemExit as e:
rc = e.code
except _OptGuardPassed:
rc = '放行'
except Exception as e:
rc = '例外 %r' % (e,)
return rc, buf.getvalue()
_optimize_bait.busy = True
try:
_optimize_level = lambda: 1
try:
rc, said = once()
finally:
_optimize_level = real
rc2, _said2 = once()
finally:
_optimize_bait.busy = False
bait = rc == 2 and '-O' in said
neg = rc2 == '放行' and _optimize_level is real and _optimize_level() == 0
return bait, neg
if __name__ == '__main__':
# --selftest 不需要執行檔,所以在 argparse 之前就攔下來(argparse 會要求一定要給路徑)。
if '--selftest' in sys.argv[1:]:
sys.exit(selftest())
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.
# ─────────────────────────────────────────────────────────