教學 › 2026 年的電腦怎麼跑得起 2005 年的遊戲

2026 年的電腦怎麼跑得起 2005 年的遊戲

這款遊戲是 2005 年為 Windows XP 做的 32 位元程式。 二十一年後的電腦跟它當年預設的世界差很多。 這一課講會遇到什麼、怎麼檢查你手上那份,以及一條流傳很久的老規矩真正的原因。

難度

★★☆ 會改執行檔

時間

約 15 分鐘

可以還原嗎

可以,一行指令

這一課不教什麼

界線在這裡:相容性修正可以教,規避授權驗證不能教。

這一課會教你改你自己那份執行檔的兩個地方:記憶體上限與螢幕解析度。 那兩件事都是「讓二十一年前的程式在今天的機器上跑得順」, 跟光碟檢查、授權驗證完全無關。

本站不教、不連結、也不討論任何規避光碟檢查或授權驗證的做法, 也不會告訴你去哪裡拿誰改好的執行檔 —— 本站的腳本改的是你自己那一份。 這是法律與版權說明第 3 節的紅線。

🛟 動手之前:先完整備份整個遊戲資料夾

把整個遊戲資料夾複製一份到別的地方, 包括裡面存放紀錄的資料夾。紀錄也可能放在遊戲資料夾外面: 「文件」裡的 Documents\MVP Baseball 2005,那一個要另外複製。

怎麼整包複製、紀錄(王朝、名單、設定)的兩個位置各在哪、找不到怎麼找: 看備份與還原 SOP 的「整包複製」。

⚡ 只想趕快做完?照這五步(要先裝好 Python,沒裝的看第 0 課操作五)

下面照 Mac 寫法;Windows 把 python3 換成 python。

  1. 下載 mvp_modernize.py(零相依,不用裝任何套件)。下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列,把字刪掉改打 cmd 按 Enter,後面的指令都在開出來的這個視窗打(Mac 開終端機先打 cd ~/MVP_Tools)。
  2. 先唯讀看你手上那顆是什麼,完全不會動到檔案
    python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe"
  3. 預覽要改哪幾個位元組,這一步還是不會寫入
    python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080
  4. 確認列出來的位元組沒問題,才真的改(會先備份,寫完自動複驗)
    python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 --apply
  5. 不對勁就一行還原
    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 位元 x862005 年的主流
大位址旗標
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 GB1.3 GB
fonts.big204 KB1.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 MB38.21 MB
data\stadium\*.big87 個各別球場檔 最大 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.big19.4 MB37.7 MB+18.3 MB
stadiums.big30.2 MB40.1 MB+9.9 MB
logos.big1.8 MB2.5 MB+0.6 MB
models.big173 MB562 MB+389 MB
data\stadium\(87 個球場檔 + lighting.csv)283 MB283 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 年的常見上限)。那不是設定檔決定的, 是寫在執行檔裡的一張表。本站在兩份剛安裝好的原版裡都找到了它:

第幾筆寬高色深
164048016 位元
264048032 位元
3 · 480060016 / 32 位元
5 · 6102476816 / 32 位元
7 · 81280102416 / 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 出現,原始碼公開)。 本站沒有使用它,但讀了它的原始碼,做法值得寫下來:

✅ 它跟本站的腳本不會打架 —— 本站實測過

它的說明有一句警告:「如果你已經用別的解析度工具改過執行檔, 請從乾淨的執行檔開始。本工具找的是遊戲原本的 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 —

只有一台機器、一份遊戲資料夾、一位測試者。沒測的:非王朝模式的球員年齡(乙處)、光碟保護的原版、中文版。

第二輪實測(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

沒測的:起始年份 2101 到 2123、非王朝模式的球員年齡(乙處)、光碟保護的原版、中文版。老闆模式的上限(2035)本站只實測了「起始 2100 直接退休」,沒有真的打到 2034 去看。

第三輪:讓王朝年份「跟著電腦時鐘走」(--dynasty-year clock)

前兩輪的做法是把年份寫死成一個數字,會有兩個麻煩:每過一年就得重做一次;而且老闆模式被一起帶著走,起始年份越晚,老闆模式能打的季數越少 (起始 2026 只剩 9 季,見上面「第二輪實測」)。測試者因此提了另一個方向: 王朝的起始年份不要寫死,改成「開新王朝的那一刻,讀電腦的日期」;老闆模式則維持原版的 2005 起算。目標是一顆執行檔就夠。

做法(不用讀機器碼也看得懂的版本):甲處那條 5 個位元組的 push 2005,換成 call 洞穴。 洞穴是一小段(133 個位元組)機器碼,放在檔案最後一個區段的空白尾端 —— 那個區段本來就可執行、檔案裡就有,所以不加區段、不改檔案大小。洞穴只做三件事:

乙處(不是王朝的模式拿來算球員年齡的 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)

怎麼做(跟其他旋鈕一樣:先預覽、再加 --apply):

python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 --dynasty-year clock

為什麼光改賽程不夠

兩者都沒碰那兩處,所以新建王朝時年份仍被甲處設成 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 流程的設定。

⚠️ 這個旋鈕的限制(先看再用)

👉 你要做的事

指令裡的 python 與 python3

下面範例照 Mac 寫法用 python3;Windows 使用者把每行開頭改成 python。

1

下載腳本

mvp_modernize.py(2,970 行,零相依)

指令要在放腳本的那個資料夾打。瀏覽器下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列(整條會反白),把字刪掉改打 cmd 按 Enter —— 開出來的命令提示字元已經停在 MVP_Tools 裡,後面的指令都在這個視窗打(第 0 課操作一的「資料夾法」)。Mac:打開終端機,先打 cd ~/MVP_Tools 按 Enter。

2

先看你手上那份是什麼(完全唯讀)

python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe"

會印出架構、記憶體上限、以及那張解析度表的八筆內容。

3

預覽要改什麼(還是不會動到檔案)

python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080

它會先講總共會改幾個位元組,再把位置列出來(位置只列前 16 個,總數以前面那一句為準)。本站在剛安裝好的英文版原版上跑上面這一行,實測是 7 個位元組,7 個位置全部列得出來。

4

確定了才真的改

python3 -I mvp_modernize.py "<遊戲資料夾>/mvp2005.exe" --4gb --resolution 1920x1080 --apply

會先備份,寫完再讀回來確認改動的位元組剛好是預期那幾個、其他一個都沒動。

5

(想固定王朝年份,或讓它跟著電腦日期走,才做)把 --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(做法與實測見第三輪)。

✅ 成功標準

第二次以後再跑 --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

Mac:Apple Silicon 也跑得動,而且本站實測過

Mac 這邊的難點跟 Windows 完全不同:

  1. macOS 從 Catalina 之後完全拿掉了 32 位元支援
  2. 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.DLLDirect3DCreate8Direct3D 8 的進入點
DDRAW.DLLDirectDrawCreateDirectDraw(2D)
DINPUT.DLLDirectInputCreateA手把與鍵盤
DSOUND.DLL(第 1 號)DirectSound
WINMM.DLLtimeGetTime高精度計時

整支執行檔裡,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,1912,389
data\datafile\datafile.big(封裝) 466 個散裝 .txt,沒有封裝檔
data\anims\anims.big(封裝) 730 個散裝檔,沒有封裝檔
mvp.ini沒有有(但遊戲不讀,見上一節)

這件事有實際後果:本站改攝影機那一課 原本寫死了要去 datafile.big 找,裝官方中文版的人從第一步就卡住。 那個問題已經修好了(工具會自動改用散裝檔),但本站把它記在這裡,因為原因值得講:

本站在此之前所有的量測都是拿英文版做的 —— 不是因為判斷過哪個比較好,而是因為手邊那份剛好是英文版。 這叫取樣偏誤:不是資料錯,是「你只量了你手上有的那一份」。 站上其他頁的數字如果標的是英文版,本站會照實寫清楚。

📋 回報範本

照著做還是跑不起來的話:

跑不起來 狀態
作業系統:Windows 10 / 11 / macOS(版本)
Mac 的話:晶片是 Intel 還是 Apple Silicon
診斷腳本說架構是:
診斷腳本說大位址旗標:
卡在哪一步:
畫面上的訊息:

📌 重點整理

這支腳本在做什麼

整支的流程是一條直線:把執行檔整份讀進記憶體 → 算出 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.
# ─────────────────────────────────────────────────────────