教學 › 執行檔上的六個旋鈕

執行檔上的六個旋鈕

一顆調好的 mvp2005.exe,跟原本那顆只差 10 個位元組。這一課把那 10 個位元組 一個一個講完:改了什麼、為什麼要改、怎麼自己改、以及好處是什麼。 每一個數字都是在兩顆真的檔案上量出來的。

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

把整個遊戲資料夾複製一份到別的地方包括裡面存放紀錄的資料夾。不要挑檔案,整包複製最省事也最保險。

「紀錄」是指這些(它們跟遊戲檔混在同一個資料夾裡):

dir /s /b "〔你的遊戲資料夾〕\*.sav"
find "〔你的遊戲資料夾〕" -name "*.sav"

上面第一行 Windows、第二行 Mac。 本站每一支工具都會自己備份它要動的那一個檔, 但那只保得住那一個檔 —— 整包備份保的是「你玩到現在的一切」。 完整做法看備份與還原 SOP

⚡ 速解

  1. 下載 mvp_exe_pe.py(零相依, 完整原始碼在這一頁下面)放到桌面。
    按了下載之後,檔案跑到哪裡去了?

    瀏覽器多半不會問你要存哪裡,它會直接放進「下載」資料夾。 所以按完之後還有一步:

    1打開檔案總管(Mac 用 Finder),左邊點「下載
    2找到那個 .py
    3把它拖到桌面

    沒做這一步的話,後面 cd 到桌面再執行, 畫面會出現 can't open file —— 那不是你打錯,是檔案還在「下載」裡。

    如果檔名多了 .txt

    第 0 課操作三把副檔名顯示出來,右鍵重新命名刪掉 .txt

  2. 先看你那顆現在什麼狀態(唯讀,不會動到檔案):

    Windows:

    python mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe"

    Mac / Linux:

    python3 mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe"
  3. 三個檔頭層面的旋鈕,一次做完(先預覽):

    Windows:

    python mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe" --iat --sections

    Mac / Linux:

    python3 mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe" --iat --sections
  4. 確定了就加 --apply

    Windows:

    python mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe" --iat --sections --apply

    Mac / Linux:

    python3 mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe" --iat --sections --apply
  5. 另外三個旋鈕在讓它在 2026 年跑起來那一課的工具:

    Windows:

    python mvp_modernize.py "〔你的遊戲資料夾〕/mvp2005.exe" --4gb --pool 128 --resolution 1920x1080 --apply

    Mac / Linux:

    python3 mvp_modernize.py "〔你的遊戲資料夾〕/mvp2005.exe" --4gb --pool 128 --resolution 1920x1080 --apply

    ⚠️ --pool照原版光碟安裝的執行檔上做不到 —— 那種檔的程式碼是加密的,找不到記憶體池的樣態。本站手上四份照正常流程安裝的遊戲 (英文版 6,972,001、官方中文版 7,525,175、 中文版+三個官方更新檔 7,533,368、社群安裝包 7,525,175 bytes) 全部印「找不到」。 而且腳本會就這樣停下來,--4gb--resolution 也不會被套用 (本站實測,檔案一個位元組都沒動)。遇到就把 --pool 128 拿掉再跑一次。 理由見中文版那一課的 「為什麼『調記憶體池』在你的遊戲上做不到」那一節 (那一段在同意閘後面,要先勾完那三項才會出現)。

  6. 後悔了就還原。兩支都套用過的話,順序是「後改的先還原」: 先還原 mvp_exe_pe.py,再還原 mvp_modernize.py

    Windows:

    python mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe" --restore
    python mvp_modernize.py "〔你的遊戲資料夾〕/mvp2005.exe" --restore

    Mac / Linux:

    python3 mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe" --restore
    python3 mvp_modernize.py "〔你的遊戲資料夾〕/mvp2005.exe" --restore

    ⚠️ 反過來做會卡住:先跑 mvp_modernize.py --restore 的話, 它會發現「現在這顆」多了 mvp_exe_pe.py 動過的位元組, 印「這份備份跟現在這顆執行檔對不上,不敢拿它覆蓋」然後停下來, 並建議你自己在檔案總管/Finder 裡複製備份 —— 照著做就會停在中間那個狀態。照上面的順序跑就不會遇到。 (只套用過其中一支的話,就只跑那一行。)

    🛟 改到一半按 Ctrl-C 也沒關係。 它會告訴你「換檔那一步」做了沒有:沒做過就是一個位元組都沒有動到, 做過了它會把該跑的 --restore 指令印出來給你。 這兩種情形都不是成功結束(結束碼 130),不會被誤當成做完了。

先講這一課的界線

⛔ 這一課不碰保護措施,一個字都不會講

MVP Baseball 2005 出貨時帶著防拷保護。你那一份能不能改、要不要處理保護, 那是你跟你自己買的那張光碟之間的事,本站 不提供、不教學、不連結任何相關資訊。

這一課講的是「保護以外的那些修改」。 本站拿來對照的那兩顆檔案,都是 5,443,584 bytes、進入點都在 0x411F00、 六個節區的位址完全一樣 —— 也就是說, 它們之間的差異沒有一個跟保護有關。

怎麼判斷你那顆能不能做這一課,見下面 「你那顆能不能改」

六個旋鈕,一張表

旋鈕改哪裡做什麼好處誰負責
記憶體池 0x3736F40x3CDD0C
各 1 位元組
push 0x04000000push 0x08000000
64 MiB → 128 MiB
中文版配大球場不再跳出。這是本站量到最有感的一項 mvp_modernize.py --pool
大位址旗標 0x00013E
1 位元組
Characteristics 0x010F0x012F(多了 0x0020 32 位元程式的位址空間從 2 GB 變 4 GB。單獨用沒感覺,是給記憶體池撐空間的 mvp_modernize.py --4gb
解析度 0x4C3FD8
8 位元組
640×4801920×1080 / 2048×1080 / 3840×2160 畫面不再是 2005 年的 640×480 mvp_modernize.py --resolution
IAT 資料目錄 0x000200
8 位元組
RVA=0 size=0RVA=0x473000 size=1264 檔頭合乎規格。填完匯入位址之後那 1,264 位元組會回到唯讀 mvp_exe_pe.py --iat
節區權限 0x2470x26F0x2E7
各 1 位元組
.text.rdata.rsrc 拿掉可寫 程式碼段不再是「可寫又可執行」。有東西想寫進程式碼會當場出錯,而不是默默改掉 mvp_exe_pe.py --sections
檔頭檢查碼 0x000180
4 位元組
壞值 → 重算出來的正確值 兩支腳本都會自動處理,不用你操心。 兩支的規則不一樣:mvp_exe_pe.py 只要動過檔頭就一定重算; mvp_modernize.py 是「原本是 0 就維持 0,原本有值才重算」, 不會替你在空欄位裡填一個新的進去 兩支都會自動處理

① 記憶體池:唯一真的會讓遊戲不當的那一個

兩個地方,各一個位元組。反組譯出來是同一條指令:

68 00 00 00 04     push 0x04000000     ← 67,108,864 = 64 MiB
68 00 00 00 08     push 0x08000000     ← 134,217,728 = 128 MiB

為什麼要改:那塊池子是 2005 年照當時主機的額度切的。 中文字型在執行時要展開成貼圖,光是這一項就吃掉 13.5 MB, 再配一座大球場就滿了 —— 二十年來大家以為「中文版配大球場會跳出」是球場太大, 其實跟球場多大沒有關係。完整的推導過程在 中文版為什麼會跳出那一課。

好處:這是六項裡唯一一項本站量到「有沒有做,結果不一樣」的。 對照實驗做過:64 MB 兩次都當、128 MB 與 512 MB 都能玩。

② 大位址旗標:單獨做沒有感覺

一個位元組,把 PE 檔頭的 Characteristics 從 0x010F 改成 0x012F,多出來的 0x0020 就是 IMAGE_FILE_LARGE_ADDRESS_AWARE。 32 位元程式預設只能用 2 GB 位址空間,掛上這個旗標之後在 64 位元 Windows 上能用到 4 GB。

⚠️ 別把它當萬靈丹

網路上很多地方把「4GB Patch」講成解決當機的方法。 本站量到的是:單獨掛這個旗標,中文版配大球場照樣跳出。 因為卡住的是遊戲自己切的那塊 64 MB 池子,不是位址空間。 這兩層是不同的天花板,掛旗標只動外面那一層。

它真正的用途是給池子撐空間:池子放大到 128 MB 甚至更多之後, 整個行程的位址需求跟著上去,這時候 2 GB 那道牆才會變成問題。 兩個一起做才有意義。

③ 解析度:八個位元組,兩個數字

0x4C3FD8 起連續八個位元組,是兩個 32 位元整數, 小端序,前面是寬、後面是高:

⚠️ 這張解析度表是成對的。每一種尺寸都存兩筆, 一筆 16 位元色深、一筆 32 位元色深,兩筆相隔 16 個位元組 (0x4C3FD8 是 640×480 的 16 位元那一筆, 0x4C3FE8 是同一個尺寸的 32 位元那一筆)。 腳本會兩筆一起換;自己動手改十六進位的話只改前面那一筆, 32 位元色深那一筆還會停在 640×480。

要什麼寬(4 位元組)高(4 位元組)原始位元組
原廠640 = 0x280 480 = 0x1E0 80 02 00 00 E0 01 00 00
1080p1920 = 0x780 1080 = 0x438 80 07 00 00 38 04 00 00
2K2048 = 0x800 1080 = 0x438 00 08 00 00 38 04 00 00
4K3840 = 0xF00 2160 = 0x870 00 0F 00 00 70 08 00 00

本站測試機上那顆 mvp2005.exe 是 4K 那一版。 ⚠️ 本站沒有實機確認介面元素在 4K 下會不會跑位 —— 版面檔是照 640×480 座標系寫的(見介面座標圖), 放大之後怎麼縮放,本站沒有量過。

④⑤ 兩個檔頭層面的修正(這兩項是一組的)

⚠️ 先講最重要的一句:這兩項不能只做一個

載入器啟動時要把每個函式的真實位址填進 匯入位址表, 而那張表就住在 .rdata 裡。 .rdata 一旦變成唯讀,載入器就必須先把那幾頁暫時改回可寫 —— 而它是照 IAT 資料目錄給的範圍去改的。

目錄是空的,它就沒有任何範圍可以解鎖,位址填不進去, 遊戲直接開不起來

所以:IAT 目錄是空的時候,.rdata 只能留著可寫。 社群那顆執行檔二十年來就是這樣繞過去的。 要收緊權限,必須同一次把目錄補回去。 腳本會擋你 —— 只加 --sections 而目錄是空的,它會停下來不寫。

④ IAT 資料目錄:PE 規格的第 13 個資料目錄要寫出 「匯入位址表在哪、多大」。本站量到的那顆是空的(RVA=0 size=0)。

腳本不是憑空造一個範圍 —— 它掃過每一筆匯入描述子的 FirstThunk,沿著指標串走到 0, 算出 RVA=0x473000 size=1264跟那顆調好的執行檔裡寫的完全相同。 這樣同一支腳本才能用在別人那顆結構不同的檔案上。

⑤ 節區權限.text(程式碼)、 .rdata(唯讀資料)、.rsrc(資源) 三個節區都帶著可寫。程式碼段又可寫又可執行是最寬鬆的組合, 正常的編譯輸出不會這樣。 收緊之後 .text 變成讀+執行,另外兩個變成唯讀。 .data 本來就該可寫,一律不碰。

⚠️ 好處講誠實一點

④⑤ 這兩項是把檔案修正確,不是效能調整。 本站沒有量到它們讓遊戲變快或變穩。

能講的好處只有這些:檔頭合乎規格,分析工具與防毒軟體不會因為 「程式碼段可寫」而多看兩眼;程式碼段唯讀之後, 如果執行時有東西想寫進程式碼,會當場出錯而不是默默改掉。

誰跟你說改這兩項會讓遊戲變順,請他拿量測出來。 同一件事在中文版那一課也記著 (那一課的重點整理在同意閘後面,要先勾完那三項才會出現)。

你那顆能不能改

先跑唯讀那一步,看它印出什麼。這一步不會動到你的檔案。

Windows:

python mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe"

Mac / Linux:

python3 mvp_exe_pe.py "〔你的遊戲資料夾〕/mvp2005.exe"

三種可能:

看到什麼意思怎麼辦
正常印出三節(IAT 目錄/節區權限/檢查碼) 檔頭讀得動,這一課的東西都能做 照速解走
「這不是 Windows 執行檔」或「找不到 PE 檔頭」 路徑指錯了,或那個檔壞了 確認路徑指到 mvp2005.exe
三節都印出來,但每一項都說「已經填好了 / 沒有需要收緊的」 你那顆本來就是好的,或者已經有人做過了 不用做。這一課對你只剩下 另外三個旋鈕

另外一個判斷點:檔案大小。 本站對照的那兩顆都是 5,443,584 bytes。 如果你那顆明顯不一樣大,它跟本站量的不是同一種來歷。 本站量到照原版光碟安裝的是 6,972,001(英文版)、 7,525,175(官方中文版)、7,533,368(中文版+三個官方更新檔)—— 那幾顆④⑤讀得動,但記憶體池那一項做不到(見上面速解第 5 步的提醒)。 比 5,443,584 還小很多的那種,超出本站能說的範圍。

❌ 出錯處理

下面除了第一列是 Python 自己印的,其餘每一句都是腳本自己會印出來的字, 看到哪一句就對哪一列。

畫面上的訊息原因你要做的
can't open file '…\mvp_exe_pe.py' 檔案還在「下載」資料夾,不在桌面。 這是本站最常見的第一個卡點。 開檔案總管左邊點「下載」,把那個 .py 拖到桌面,再跑一次
這顆的 IAT 資料目錄是空的,現在把節區改成唯讀,遊戲會開不起來。 你只加了 --sections 沒加 --iat這兩項是一組的,目錄空著又把節區設唯讀,載入器沒有範圍可以解鎖來填匯入位址 --iat 一起加上去。 這一步是擋下來,不是寫壞了 —— 檔案一個位元組都沒動
…是一個符號連結,指向… 執行檔或備份那個位置躺著一個指向資料夾外面的連結。 跟過去就會寫到別的檔案上 把那個連結移走,或直接對真正的檔案操作
這份備份是壞的,不敢拿它覆蓋… 備份是 0 位元組或只剩半截(多半是上次備份途中被中斷)。 拿它還原會把你的正本吃掉,所以擋下來 改用你自己另外留的那一份備份 (備份與還原 SOP
找不到備份:… 還沒跑過 --apply,或 .exepebak 被搬走了 確認遊戲資料夾裡有沒有那個 .exepebak
❌ 複驗失敗:寫回去的內容跟預期不同。 寫進去的東西跟計畫對不上 它已經自動用剛剛驗過的備份還原回去了, 畫面上會說結果。自動還原沒成功的話,它會把 --restore 那一行印出來給你照著跑
你按了 Ctrl-C。換檔那一步還沒發生,你的檔案一個位元組都沒有動到。 你中途中斷了,而且是在換檔之前 不用做任何事,檔案一個位元組都沒有動到。 另一種寫法是「但檔案已經換過去了」,那就照它印的 --restore 還原

💡 要寫成批次檔的話,看結束碼就夠了0 = 做完了、1 = 有守門攔下來或複驗失敗、 2 = 參數或路徑不對、130 = 你按了 Ctrl-C。 被攔下來或被中斷都不會回 0

驗證:10 個位元組,一個一個對過

這一課的每個數字都是從兩顆真的檔案量出來的: 一顆是社群 2023 年那包裡的 mvp2005.exe, 一顆是照這一課調好的那顆。兩顆都是 5,443,584 bytes,長度完全沒變。

Windows:

python mvp_modernize.py mvp2005.exe --4gb --pool 128 --apply
python mvp_exe_pe.py   mvp2005.exe --iat --sections --apply

Mac / Linux:

python3 mvp_modernize.py mvp2005.exe --4gb --pool 128 --apply
python3 mvp_exe_pe.py   mvp2005.exe --iat --sections --apply

跑完之後把成品跟原本那顆逐位元組比對,程式碼與檔頭旗標動到的剛好是這 10 個:

⚠️ 你自己照上面兩行跑,會量到 14 個,那是對的。 多出來的 4 個是⑥檔頭檢查碼(0x0001800x000183)—— 兩支腳本都會自動重算它,而本站拿來對照的那顆保留了原本的舊值沒有重算, 所以那 4 格不在下面這張表裡。本站實測:跑完之後成品對原版差 14 個位元組,對參考那顆差 4 個,全部落在檢查碼那一欄。

位移幾個位元組是什麼
0x3736F41 記憶體池 ① 64128 MiB
0x3CDD0C1 記憶體池 ② 64128 MiB
0x00013E1 大位址旗標
0x0002014 IAT 資料目錄(RVA=0x473000 size=1264)。 腳本畫面上會說「8 個位元組」,那是整個欄位的大小 —— 新值裡有 4 個位元組本來就是 0x00,所以真正變掉的是 4 個
0x0002471 .text 拿掉可寫
0x00026F1 .rdata 拿掉可寫
0x0002E71 .rsrc 拿掉可寫

解析度不在這張表裡,因為那顆沒有改解析度。 要改的話是 mvp_modernize.py --resolution 1920x1080; 腳本會把 0x4C3FD8(16 位元色深)與 0x4C3FE8 (32 位元色深)兩筆一起換,各寫八個位元組 —— 換成 1080p 時真正變掉的是六個。

有一個旗標本站刻意不改

那顆檔案有第六個沒有名字的節區。本站的腳本原本的規則是 「凡是帶程式碼旗標又可寫的都收緊」,那會連它一起關掉寫入。

載入器有沒有需要寫進那一段,本站還沒有在 Windows 上實測過這一項。 所以規則改成白名單:只認 .text.rdata.rsrc 這三個標準名字,其他一律不碰。 在測不到的地方,不要憑推理去動它。

這一課到哪裡為止(本站沒驗的)

這支腳本在做什麼

mvp_exe_pe.py 只做檔頭層面的三件事,不碰任何一個位元組的程式碼。 流程是:解析 PE 檔頭 → 算出該填什麼 → 寫到暫存檔再換過去 → 逐位元組複驗。

哪一段做什麼為什麼要有它
parse_pe() 0x3C 找到 PE 檔頭,算出 Optional Header、 資料目錄、節區表、檢查碼各在哪 後面每一步都要用這些位移。集中在一個地方算, 才不會四散在各處各算一次
rva_to_off() 把虛擬位址換算成檔案位移 檔頭裡寫的全是虛擬位址, 但我們要動的是檔案。兩者的對應由節區表決定,不是固定偏移量
compute_iat_range() 掃過每一筆匯入描述子的 FirstThunk, 沿著指標串走到 0,取最小起點與最大結尾 IAT 的範圍是算出來的,不是抄來的。 這樣同一支腳本才能用在別人那顆結構不同的檔案上
sections_to_tighten() 挑出 .text.rdata.rsrc目前可寫的,回傳關掉可寫之後的新旗標 白名單而不是規則判斷(理由見上面那張綠卡)。 另外它會先確認 IAT 目錄不是空的才肯動, 不然改完遊戲會開不起來
_recalc_checksum() 16 位元加總、進位摺回、再加檔案大小 動過檔頭就一定要重算, 不然那個欄位會變錯。參考的那顆原本就是錯的
report() 把三項的現況印出來,什麼都不寫 預設就是唯讀。要動手必須明講參數, 而且還要再加 --apply 才會真的寫
apply_changes() 列出計畫 → 備份 → 寫暫存檔 → os.replace 換過去 → 讀回來逐位元組複驗 備份與寫入都走「先寫暫存再換」, 中途斷電不會留下半截檔案。複驗確認動到的剛好是計畫裡那些而且寫之前先擋符號連結 —— 備份路徑是拿檔名組出來的, 那裡若躺著一個指向資料夾外的連結,跟過去就會寫到別的檔案上。 複驗不過會自動用備份還原,並且以非 0 結束碼收場, 不會印一行 ❌ 然後假裝成功
_restore_from_backup() 還原之前先驗備份:不是 0 位元組、 MZ/PE 節區表算得出來的長度不超過檔案、大小不能只剩一小半。 通過之後也不直接覆蓋正本 —— 寫同資料夾的暫存檔 → fsync → 讀回逐位元組比對 → os.replace 換上 2026-08-30 實測過:半截備份會把玩家的正本吃掉。 全站每一支會還原的腳本都帶這一段。 2026-09-05 唯讀稽核又抓到一件:舊寫法 shutil.copy2 會先把正本截成 0 再複製,實測還原途中出錯,會讓 5,443,584 bytes 的執行檔剩 680,448
selftest() 自己造一顆最小的 PE 出來,跑完整流程再驗結果。 python3 mvp_exe_pe.py --selftest(Windows 打 python), 十項全過才會印「總判定: ✅ 全部通過」 不需要遊戲檔就能證明它是對的。 包含十個區塊,每一道守門都下了餌 —— 把守門拆掉自我測試就會變紅。 除了原本兩個陰性對照(改過的檔再跑一次不該再動它、半截備份不可以蓋掉正本), 還有還原原子性、符號連結守衛(含指向不存在目標的斷鏈)、 寫入原子性、Ctrl-C 記錄這四類

另外三個旋鈕(記憶體池、大位址、解析度)在 讓它在 2026 年跑起來那一課的 mvp_modernize.py,那一課也有同樣的一節說明。

完整原始碼

mvp_exe_pe.py(零相依,Python 3.7 以上)。 下面這一份跟你下載到的 一個字都沒有不同。可以先看過再決定要不要跑。

展開 / 收合完整原始碼(1156 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_exe_pe.py

把一顆 MVP Baseball 2005 執行檔的 **PE 檔頭修回合乎規格**。
兩件事,兩件都不碰程式碼本身:

  ① 補回 IAT 資料目錄   ② 收緊節區的寫入權限

  看你那顆現在是什麼狀態(唯讀,不會動到檔案)
      python3 mvp_exe_pe.py "你的遊戲資料夾/mvp2005.exe"

  預覽要改什麼(還是不會動到檔案)
      python3 mvp_exe_pe.py "…/mvp2005.exe" --iat --sections

  真的改(會先備份成 .exepebak)
      python3 mvp_exe_pe.py "…/mvp2005.exe" --iat --sections --apply

  還原
      python3 mvp_exe_pe.py "…/mvp2005.exe" --restore

  自我測試(不需要遊戲檔)
      python3 mvp_exe_pe.py --selftest

── 這支在做什麼、為什麼要做 ────────────────────────────────

**① IAT 資料目錄**
PE 規格的第 13 個資料目錄(索引 12)叫 Import Address Table,
它要指出「匯入位址表在哪、多大」。有些執行檔這一欄是 `RVA=0, size=0`,
等於沒填。Windows 載入器不靠它也載得起來(它會走匯入描述子),
但這個欄位是**載入器把那一段暫時改成可寫、填完位址再改回唯讀**的依據。
沒填 = 那一段的保護狀態由節區權限決定,而節區權限那時候常常是可寫的(見②)。
補回去之後,那一段在填完匯入位址之後會回到唯讀。

**② 節區寫入權限**
有些執行檔的 `.text`(程式碼)、`.rdata`(唯讀資料)、`.rsrc`(資源)
三個節區都帶著 **可寫** 旗標。程式碼段可寫又可執行是最寬鬆的組合,
正常的編譯輸出不會這樣。
收緊之後:`.text` 變成 讀+執行,`.rdata` 與 `.rsrc` 變成 唯讀。

**好處是什麼(誠實版)**
· 檔頭合乎規格,分析工具與防毒軟體不會因為「程式碼段可寫」而起疑。
· 程式碼段唯讀之後,執行時如果有東西試圖寫進程式碼,會當場出錯而不是默默改掉。
· ⚠️ **本站沒有量到它讓遊戲變快或變穩**。這兩項是「把檔案修正確」,
  不是效能調整。誰跟你說改這個會變順,請他拿量測出來。

**做不到、也不會做的事**
· 這支**不碰任何保護措施,也不會告訴你怎麼處理保護**。你那顆能不能改,
  是你自己那份遊戲的事。
· 這支不改程式碼、不改解析度、不改記憶體池、**不動任何時間戳**。
  那幾項在「讓它在 2026 年跑起來」那一課的 mvp_modernize.py。
· 改完檔頭會自己重算檢查碼,詳見下面 `_recalc_checksum()`。

備份副檔名 `.exepebak`,只認自己這一個,不會去動別課留下的 .bak。

無外部相依,Python 3.7 以上即可。
授權:MIT(見檔尾完整條款)。本站教學文字另採 CC BY 4.0。
"""
#
# ─────────────────────────────────────────────────────────
#  法律與免責(每一支本站腳本都帶著這一段)
#
#  · 本工具與 Electronic Arts 無任何官方關聯,也未經其授權或背書。
#    MVP Baseball 2005 為 Electronic Arts 之作品與商標。
#  · 本工具為原創程式碼,**不含任何 EA 的程式碼或資產**。
#  · 本工具不提供、不教學、也不包含任何規避技術保護措施的功能。
#  · 使用者應僅對自己合法取得的遊戲副本使用本工具,並自行承擔風險。
#    使用前請自行確認你與遊戲發行商之間的使用者授權合約(EULA)。
#  · 本工具按「現狀」提供,不附任何明示或默示的擔保。
#  · 授權:MIT(見檔尾)。教學文字另採 CC BY 4.0。
#  · 回報與下架:https://toniliumvp.github.io/MVPBaseball/report.html
#    三條管道,其中「直接向 GitHub 提出」不需經過維護者;
#    留言區那條不需要任何帳號。管道有變動只會改那一頁。
# ─────────────────────────────────────────────────────────
import argparse
import io
import os
import shutil
import signal
import struct
import sys
import tempfile

BAK = '.exepebak'

# PE 規格裡這兩個旗標的值。收緊權限就是把 WRITE 那一位關掉。
SCN_CODE = 0x00000020            # IMAGE_SCN_CNT_CODE
SCN_INITIALIZED = 0x00000040     # IMAGE_SCN_CNT_INITIALIZED_DATA
SCN_EXECUTE = 0x20000000
SCN_READ = 0x40000000
SCN_WRITE = 0x80000000           # ← 要關掉的就是這一位

# 資料目錄的索引。12 = Import Address Table(這支要補的那一個)。
DIR_IMPORT = 1
DIR_IAT = 12


class DataError(Exception):
    """檔案不是預期的樣子。一律用這個丟,好跟程式自己的 bug 分開。"""


# ─────────────────────────────────────────────────────────
#  讀 PE 檔頭
#
#  這一段只做「把檔頭的位置算出來」,不做任何判斷。
#  所有位移都照 PE/COFF 規格:
#    0x3C            → PE 檔頭在哪(e_lfanew)
#    PE+0            → "PE\0\0"
#    PE+6            → 節區數量
#    PE+20           → Optional Header 有多大
#    PE+24           → Optional Header 開始
#    Optional+64     → CheckSum
#    Optional+92     → 資料目錄有幾個
#    Optional+96     → 資料目錄第一筆
#    Optional+size   → 節區表開始,每筆 40 bytes
# ─────────────────────────────────────────────────────────
def parse_pe(d):
    """回傳一個 dict,裝著這支會用到的所有位移。不是 PE 就丟 DataError。"""
    if len(d) < 0x40 or d[:2] != b'MZ':
        raise DataError('這不是 Windows 執行檔(開頭不是 MZ)。')
    pe = struct.unpack_from('<I', d, 0x3C)[0]
    if pe <= 0 or pe + 24 > len(d) or d[pe:pe + 4] != b'PE\0\0':
        raise DataError('找不到 PE 檔頭,這個檔可能壞了或不是 PE 執行檔。')
    nsec = struct.unpack_from('<H', d, pe + 6)[0]
    optsz = struct.unpack_from('<H', d, pe + 20)[0]
    opt = pe + 24
    if opt + optsz > len(d):
        raise DataError('Optional Header 宣告的長度超出檔案結尾。')
    ndir = struct.unpack_from('<I', d, opt + 92)[0]
    sect = opt + optsz
    if sect + nsec * 40 > len(d):
        raise DataError('節區表超出檔案結尾,宣告 %d 個節區。' % nsec)
    return {'pe': pe, 'opt': opt, 'nsec': nsec, 'ndir': ndir,
            'dir': opt + 96, 'sect': sect, 'checksum': opt + 64}


def read_dir(d, h, idx):
    """讀第 idx 個資料目錄,回傳 (RVA, size)。超出範圍就回 (0, 0)。"""
    if idx >= h['ndir']:
        return (0, 0)
    return struct.unpack_from('<II', d, h['dir'] + idx * 8)


def read_sections(d, h):
    """把節區表讀成一串 dict。名稱、虛擬位址、大小、旗標,以及旗標欄的位移。"""
    out = []
    for i in range(h['nsec']):
        b = h['sect'] + i * 40
        name = d[b:b + 8].rstrip(b'\0').decode('latin1') or '(無名)'
        vsize, vaddr, rsize, raddr = struct.unpack_from('<IIII', d, b + 8)
        flags = struct.unpack_from('<I', d, b + 36)[0]
        out.append({'name': name, 'vaddr': vaddr, 'vsize': vsize,
                    'raddr': raddr, 'rsize': rsize,
                    'flags': flags, 'flags_off': b + 36})
    return out


def describe_flags(f):
    """把節區旗標翻成人看得懂的幾個字。"""
    bits = []
    if f & SCN_CODE:
        bits.append('程式碼')
    if f & SCN_INITIALIZED:
        bits.append('資料')
    perm = ''.join(('讀' if f & SCN_READ else '',
                    '寫' if f & SCN_WRITE else '',
                    '執行' if f & SCN_EXECUTE else ''))
    return '%s · %s' % ('+'.join(bits) or '?', perm or '無權限')


# ─────────────────────────────────────────────────────────
#  ① 補回 IAT 資料目錄
#
#  匯入位址表(IAT)是一串指標,程式啟動時由 Windows 載入器把每個
#  函式的真實位址填進去。它的位置藏在匯入描述子的 FirstThunk 欄位裡,
#  而 PE 規格另外要求把「整段 IAT 的起點與長度」填在第 13 個資料目錄(索引 12)。
#
#  ⚠️ 那個目錄**不是**給載入器找 IAT 用的(它走匯入描述子就找得到)。
#     它的用途是讓載入器知道「這一段要暫時開放寫入,填完再改回去」。
#     沒填的話,那一段的可寫與否就完全由節區權限決定。
#
#  這裡不是憑空造一個範圍出來,而是**從匯入描述子自己算出來的**:
#  掃過每一筆描述子的 FirstThunk,取最小的當起點;
#  再沿著那一串 thunk 走到 0 為止,取最大的結尾。
# ─────────────────────────────────────────────────────────
def rva_to_off(secs, rva):
    """把虛擬位址換成檔案位移。落在任何節區之外就回 None。"""
    for s in secs:
        if s['vaddr'] <= rva < s['vaddr'] + max(s['vsize'], s['rsize']):
            return s['raddr'] + (rva - s['vaddr'])
    return None


def compute_iat_range(d, h, secs):
    """從匯入描述子算出 IAT 應該是 (RVA, size)。算不出來就回 None。"""
    imp_rva, imp_size = read_dir(d, h, DIR_IMPORT)
    if not imp_rva:
        return None
    off = rva_to_off(secs, imp_rva)
    if off is None:
        raise DataError('匯入表的位址 0x%08X 不落在任何節區裡。' % imp_rva)

    lo, hi = None, None
    k = 0
    while True:
        b = off + k * 20
        if b + 20 > len(d):
            break
        fields = struct.unpack_from('<IIIII', d, b)
        if not any(fields):          # 全部是 0 = 描述子表結束
            break
        first_thunk = fields[4]
        if first_thunk:
            t = rva_to_off(secs, first_thunk)
            if t is None:
                raise DataError('第 %d 筆匯入的 FirstThunk 0x%08X 不在任何節區裡。'
                                % (k, first_thunk))
            # 沿著這一串指標走到 0 為止,一筆 4 bytes(32 位元執行檔)
            n = 0
            while t + n * 4 + 4 <= len(d):
                if struct.unpack_from('<I', d, t + n * 4)[0] == 0:
                    break
                n += 1
            n += 1                    # 把結尾那個 0 也算進去,規格是這樣算的
            start, end = first_thunk, first_thunk + n * 4
            lo = start if lo is None else min(lo, start)
            hi = end if hi is None else max(hi, end)
        k += 1
        if k > 4096:                  # 防呆:描述子表壞掉時不要無限跑
            raise DataError('匯入描述子超過 4096 筆,這個檔看起來壞了。')
    if lo is None:
        return None
    return (lo, hi - lo)


# ─────────────────────────────────────────────────────────
#  ② 收緊節區權限
#
#  規則很簡單:**只關掉不該有的寫入權限,絕不打開任何權限。**
#  只動這三種節區,而且只在它們現在真的可寫的時候動:
#    · 帶「程式碼」旗標的      → 程式碼不該可寫
#    · .rdata(唯讀資料)      → 名字就叫唯讀
#    · .rsrc(資源)           → 資源是唯讀資料
#  `.data` 這種本來就該可寫的,一律不碰。
# ─────────────────────────────────────────────────────────
TIGHTEN_NAMES = ('.text', '.rdata', '.rsrc')


def sections_to_tighten(secs):
    """回傳 [(節區, 新旗標)],只列真的需要動的。

    ⚠️ **只動這三個有名字的節區,不動任何其他節區。**

    這一條是量出來的,不是推理出來的。本站拿來當參考的那顆執行檔
    (班主任 2026 年那顆)除了這三個之外,還有第六個**沒有名字**的節區,
    裡面裝的是被工具重建過的匯入表。那顆的作者把它留成**可寫**,
    只拿掉了「內容是資料」那個旗標。

    這支原本的規則是「凡是帶程式碼旗標又可寫的都收緊」,那會連那個
    無名節區一起關掉寫入 —— **比參考實作更激進**。載入器有沒有需要
    寫進那一段,本站還沒有在 Windows 上實測過這一項。

    在測不到的地方,**參考實作就是真相,不要憑推理改進它**。
    所以規則改成白名單:只認 .text / .rdata / .rsrc 這三個標準名字。
    """
    out = []
    for s in secs:
        if not (s['flags'] & SCN_WRITE):
            continue                  # 已經是唯讀,不用動
        if s['name'] in TIGHTEN_NAMES:
            out.append((s, s['flags'] & ~SCN_WRITE))
    return out


def _recalc_checksum(d, h):
    """PE 檔頭的 CheckSum:16 位元加總(進位摺回)再加上檔案大小。

    計算時把 CheckSum 欄位自己當成 0,這是規格規定的。
    改完檔頭一定要重算,不然那個欄位就變成錯的
    (本站量到的那顆原本就是錯的:檔頭裡存的那個值跟實算的 0x0053C452 對不上)。
    """
    b = bytearray(d)
    struct.pack_into('<I', b, h['checksum'], 0)
    s = 0
    for i in range(0, len(b) & ~1, 2):
        s += struct.unpack_from('<H', b, i)[0]
        s = (s & 0xFFFF) + (s >> 16)
    if len(b) & 1:
        s += b[-1]
        s = (s & 0xFFFF) + (s >> 16)
    return (s + len(b)) & 0xFFFFFFFF


# ─────────────────────────────────────────────────────────
#  寫檔的共用底座:符號連結守門 + 原子換檔
#
#  ⚠️ 這一段是 2026-09-05 唯讀稽核之後補的。讀者的遊戲檔絕對不能被弄壞,
#     所以「寫入」這件事在這支裡只有一條路徑,而且那條路徑保證:
#     要嘛整份換好,要嘛正本一個位元組都沒動。
# ─────────────────────────────────────────────────────────

# 換檔那一步到底做過沒有。按 Ctrl-C 的時候要靠它才說得出實話。
# **三態,不是兩態** ——
#   'none'       還沒走到換檔那一步     → 可以說「一個位元組都沒有動到」
#   'replacing'  正要換、還沒確定換完    → 只能說「中斷時正在替換,請 --restore 或比對備份」
#   'done'       os.replace() 已經做完   → 要說「已經改了,用 --restore」
# 少了中間那一態,Ctrl-C 剛好落在 os.replace() 與登記之間就會謊報。
_WROTE_TO_TARGET = {'path': None, 'state': 'none'}


def _reset_mark():
    """把三態旗標歸零。只有自我測試會用到 —— 正式流程一次只換一個檔。"""
    _WROTE_TO_TARGET['path'] = None
    _WROTE_TO_TARGET['state'] = 'none'


class _NoInterrupt(object):
    """把「os.replace + 登記」包成一段不會被 Ctrl-C 切開的動作。

    這段期間收到的 SIGINT 先記著,離開這段之後再照常丟出來。
    這樣 KeyboardInterrupt 的收尾看到的登記,一定跟磁碟上的真實狀態一致 ——
    不會出現「檔案其實已經換過去了,而旗標還停在沒換」的那半行空隙。

    ⚠️ 只**延後**,不吞掉:該丟的 KeyboardInterrupt 離開區塊時還是會丟。
    ⚠️ 不是主執行緒的話 signal.signal() 會失敗,那就退回原本的行為 ——
       不會比以前更糟,也不會因為裝不上處理器就讓整件事失敗。
    """

    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 _refuse_symlink(p, what):
    """目的地是符號連結就拒絕,不跟過去。

    ⚠️ 用 os.path.islink() 不是 os.path.exists():後者對「指向不存在的目標」
       的連結會回 False,等於看不見它,然後就一路跟出去寫到資料夾外面。
       備份路徑是這支自己從檔名組出來的(檔名 + .exepebak),
       那個位置剛好躺著一個連結是完全可能的事。
    """
    p = os.fspath(p)
    if os.path.islink(p):
        raise SystemExit(
            '%s 是一個符號連結,指向 %s。\n'
            '  不敢跟著它寫過去(那會動到這個資料夾以外的檔案)。\n'
            '  請把那個連結移走,或直接對真正的檔案操作。'
            % (what, os.path.realpath(p)))


def _copy_times(src, dst):
    """把時間戳等中繼資料帶過去。純粹是為了跟舊版 shutil.copy2 的行為一致 ——
    失敗不影響檔案內容,所以吞掉例外就好,不要為了時間戳讓整件事失敗。"""
    try:
        shutil.copystat(src, dst)
    except OSError:
        pass


def _atomic_write(dst, data, mode_from=None, tmp_tag='tmp', mark_target=False):
    """把 data 寫成 dst,而且**中途失敗絕不留下半截的 dst**。

    順序:同資料夾開一個唯一暫存檔(tempfile.mkstemp —— 名字不可預測,
    也不會跟別人的檔撞名)→ 寫入 → flush → os.fsync(真的落到碟上)→
    把原本的權限帶過去 → **讀回來逐位元組比對** → 最後才 os.replace() 換上。
    os.replace() 在同一個檔案系統內是原子的:要嘛整份換過去,要嘛完全沒換。
    任何一步失敗就把暫存檔刪掉,dst 一個位元組都不會動到。

    ⚠️ 絕對不可以退回 shutil.copy2(來源, dst):copy2 會**先把 dst 截成
       0 bytes** 再慢慢複製,中途 Ctrl-C、磁碟滿、外接碟被拔掉,就留下一顆
       半截的遊戲檔 —— 而站上每一課都寫著「隨時可以 --restore」。
    """
    dst = os.fspath(dst)
    _refuse_symlink(dst, '要寫入的目標(%s)' % os.path.basename(dst))
    folder = os.path.dirname(os.path.abspath(dst)) or '.'
    fd, tmp = tempfile.mkstemp(
        dir=folder, prefix='.%s.%s-' % (os.path.basename(dst), tmp_tag))
    try:
        with os.fdopen(fd, 'wb') as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())
        if mode_from and os.path.isfile(mode_from):
            # mkstemp 一律 0600,要把原本的權限帶過去,不然還原完檔案權限就變了
            shutil.copymode(mode_from, tmp)
        with open(tmp, 'rb') as f:
            if f.read() != data:
                raise DataError('暫存檔讀回來跟要寫的內容不一樣,不敢拿它換掉正本。')
        if mark_target:
            # ⚠️ 進不可中斷區之前先登記「正在換」。Ctrl-C 剛好落在這裡的話,
            #    收尾會說「中斷時正在替換,請 --restore 或比對備份」——
            #    既不謊報「什麼都沒動到」,也不謊報「已經換好了」。
            _WROTE_TO_TARGET['path'] = dst
            _WROTE_TO_TARGET['state'] = 'replacing'
        # ⚠️ 換名與登記必須綁在一起。中間被 Ctrl-C 切開的話,收尾看到的旗標
        #    就跟磁碟上的真實狀態對不起來 —— 那正是「說謊」的來源。
        with _NoInterrupt():
            os.replace(tmp, dst)
            if mark_target:
                _WROTE_TO_TARGET['state'] = 'done'
    except BaseException:
        try:
            os.remove(tmp)
        except OSError:
            pass
        # 走到這裡代表換名**沒有發生**:要嘛還沒進不可中斷區,要嘛 os.replace()
        # 自己丟了例外(同一個檔案系統內它是原子的,丟例外就是沒換)。
        # **只有這種情形**才可以把旗標收回去;已經 'done' 的絕對不收。
        if mark_target and _WROTE_TO_TARGET['state'] == 'replacing':
            _reset_mark()
        raise


def _restore_from_backup(bak, dst):
    """還原之前先擋掉明顯壞掉的備份。

    ⚠️ 這裡**不能**比對「備份與目標大小相同」—— 本站多數腳本是把資料接到
    檔尾來改檔(專案鐵律:封裝檔不可重新打包),改完之後正本本來就比備份大,
    那樣比會擋掉每一次合法的還原。

    ── 2026-08-30 補上三道(上線前資安稽核抓到的真漏洞)────────────────
    原本只有「不是 0 bytes」+「BIGF 檔頭宣告長度」兩道。**BIGF 以外全破。**
    實測拿「前 1/8 的半截備份」去還原,六支腳本把正本吃掉而且都印成功:
        mvp_fix_loc / mvp_menu_text   .LOC        416,753 →  52,094
        mvp_edit_speed / mvp_ratings
        / mvp_player                  attrib.dat  840,643 → 105,080
        mvp_modernize                 mvp2005.exe 5,443,584 → 680,448
                                      (它還印「複驗:內容與備份相同 ✅」)
    最後那個會讓遊戲**完全開不起來**,而站上每一課都寫著「隨時可以 --restore」。

    現在檢查五件事:
      1. 備份不是 0 bytes
      2. BIGF:檔頭第 4-8 個位元組宣告的總長度要等於實際長度
         (兩種位元組序都接受;哪些檔是大端、各有幾個,以 reference/bigf.html 量到的為準,這裡不寫會過期的數字)
      3. LOCH(語系檔):檔頭指到的 LOCL 要在檔內,而且最後一條字串的位移
         也要在檔內 —— 截斷之後那個位移一定會超出去
      4. MZ(執行檔):PE 節區表裡 raw offset + raw size 的最大值不得超過檔案長度
      5. **通用地板**:非 BIGF 的備份不得小於「要被蓋掉的那個檔」的一半。
         非 BIGF 的工具都是原地改(大小幾乎不變),所以這條很安全;
         BIGF 走 append 會越改越大,所以刻意**不套**這條,由第 2 道負責。
    """
    import struct
    bak, dst = os.fspath(bak), os.fspath(dst)
    # 兩端都不可以是符號連結。備份是連結 = 拿資料夾外的內容蓋遊戲檔;
    # 目標是連結 = 寫穿到資料夾外面。兩種都在這裡擋掉,不跟過去。
    _refuse_symlink(bak, '備份檔(%s)' % os.path.basename(bak))
    _refuse_symlink(dst, '要還原的目標(%s)' % os.path.basename(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))

    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)

    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('讀不出語系檔的結構,它壞了。')

    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 bytes 或半截狀態。
    還原後的比對只能發現「已經壞了」,擋不住正本先被截斷。

    現在走 _atomic_write():暫存檔 → fsync → 讀回逐位元組比對 → os.replace。
    失敗時正本原封不動,而且會照實說是哪一步失敗的。
    """
    bak, dst = os.fspath(bak), os.fspath(dst)
    _refuse_symlink(bak, '備份檔(%s)' % os.path.basename(bak))
    with open(bak, 'rb') as f:
        data = f.read()
    # 權限來源:正本還在就照正本的;正本被刪掉(最需要還原的那種情境)就照備份的
    _atomic_write(dst, data, mode_from=(dst if os.path.isfile(dst) else bak),
                  tmp_tag='restore', mark_target=True)
    _copy_times(bak, dst)        # 舊版走 copy2,還原後會帶著備份的時間戳,維持一樣


# Windows 主控台預設編碼(繁中是 cp950)存不下 ⚠️ ✅ 這類符號,
# 輸出被重導向到檔案時會直接 UnicodeEncodeError 中斷。先把輸出轉成 UTF-8。
# ⚠️ 這一段非補不可:--apply 是「先寫檔、再印成功訊息」,印到一半炸掉的話
#    檔案其實已經改好了,讀者卻只看到 traceback,會以為失敗而重跑。
try:
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except (AttributeError, ValueError):                # Python 3.6 以下沒有 reconfigure
    pass


# ─────────────────────────────────────────────────────────
#  報告:唯讀,什麼都不會動到
# ─────────────────────────────────────────────────────────
def report(path):
    d = io.open(path, 'rb').read()
    h = parse_pe(d)
    secs = read_sections(d, h)
    print('檔案:%s' % os.path.basename(path))
    print('大小:%s bytes' % format(len(d), ','))

    iat_rva, iat_size = read_dir(d, h, DIR_IAT)
    print('\n① IAT 資料目錄')
    if iat_rva:
        print('   已經填好了:RVA=0x%08X size=%d' % (iat_rva, iat_size))
    else:
        print('   ⚠️ 沒有填(RVA=0 size=0)')
        want = compute_iat_range(d, h, secs)
        if want:
            print('   從匯入描述子算出來應該是:RVA=0x%08X size=%d' % want)
        else:
            print('   這個檔沒有匯入表,算不出來。')

    print('\n② 節區權限')
    for s in secs:
        mark = ''
        if (s['flags'] & SCN_WRITE) and ((s['flags'] & SCN_CODE)
                                         or s['name'] in TIGHTEN_NAMES):
            mark = ('  ⚠️ 可寫' if s['name'] in TIGHTEN_NAMES
                    else '  ⚠️ 可寫(不在白名單,這支不動它)')
        print('   %-9s 0x%08X  %s%s'
              % (s['name'], s['flags'], describe_flags(s['flags']), mark))
    todo = sections_to_tighten(secs)
    print('   %s' % ('要收緊的有 %d 個:%s'
                     % (len(todo), '、'.join(s['name'] for s, _ in todo))
                     if todo else '沒有需要收緊的 ✅'))

    stored = struct.unpack_from('<I', d, h['checksum'])[0]
    calc = _recalc_checksum(d, h)
    print('\n③ 檔頭檢查碼')
    print('   檔內寫的 0x%08X · 實算 0x%08X  %s'
          % (stored, calc, '相符 ✅' if stored == calc else '⚠️ 不符'))
    print('\n(以上全部是讀出來的,這一步不會動到你的檔案。)')


# ─────────────────────────────────────────────────────────
#  真的改:先備份,寫到暫存檔再換過去,最後逐位元組複驗
# ─────────────────────────────────────────────────────────
def apply_changes(path, do_iat, do_sections, apply_it):
    d = io.open(path, 'rb').read()
    h = parse_pe(d)
    secs = read_sections(d, h)
    out = bytearray(d)
    plan = []

    if do_iat:
        rva, size = read_dir(d, h, DIR_IAT)
        if rva:
            print('  ⓘ IAT 目錄本來就填好了,這一項不用做。')
        else:
            want = compute_iat_range(d, h, secs)
            if not want:
                print('  ⓘ 這個檔沒有匯入表,IAT 目錄這一項跳過。')
            else:
                struct.pack_into('<II', out, h['dir'] + DIR_IAT * 8, *want)
                plan.append('補回 IAT 資料目錄(RVA=0x%08X size=%d,8 個位元組)' % want)

    if do_sections:
        # ⚠️ 這兩項有先後關係,不是各自獨立的。
        #
        #    載入器要把匯入位址填進 .rdata,而 .rdata 一旦變成唯讀,
        #    它就必須先把那幾頁暫時改回可寫 —— **而它是照 IAT 資料目錄
        #    給的範圍去改的**。目錄是空的,它就沒有任何範圍可以解鎖,
        #    填不進去,遊戲直接開不起來。
        #
        #    也就是說:**IAT 目錄是空的時候,.rdata 只能留著可寫。**
        #    二十年來社群那顆執行檔就是這樣繞過去的。
        #    要收緊權限,必須同一次把目錄補回去。
        #
        #    所以這裡擋下來,而不是寫完才發現遊戲開不起來。
        iat_rva, _iat_sz = read_dir(out, h, DIR_IAT)
        if not iat_rva:
            raise DataError(
                '這顆的 IAT 資料目錄是空的,現在把節區改成唯讀,遊戲會開不起來。\n'
                '        載入器要靠那個目錄才知道哪一段可以暫時解鎖來填匯入位址。\n'
                '        請把 --iat 一起加上去(兩個一起做才安全)。')
        todo = sections_to_tighten(secs)
        if not todo:
            print('  ⓘ 沒有節區需要收緊,這一項不用做。')
        for s, new in todo:
            struct.pack_into('<I', out, s['flags_off'], new)
            plan.append('%s 拿掉可寫(0x%08X → 0x%08X,4 個位元組)'
                        % (s['name'], s['flags'], new))

    if not plan:
        print('\n沒有要改的東西。')
        return

    # 檔頭動過就一定要重算檢查碼,不然那個欄位會變成錯的
    before_ck = struct.unpack_from('<I', out, h['checksum'])[0]
    struct.pack_into('<I', out, h['checksum'], 0)
    new_ck = _recalc_checksum(bytes(out), h)
    struct.pack_into('<I', out, h['checksum'], new_ck)
    if new_ck != before_ck:
        plan.append('重算檔頭檢查碼(0x%08X → 0x%08X,4 個位元組)' % (before_ck, new_ck))

    print('\n要做的事:')
    for x in plan:
        print('  · %s' % x)
    changed = sum(1 for i in range(len(d)) if d[i] != out[i])
    print('\n總共會動到 %d 個位元組(檔案長度不變:%s bytes)'
          % (changed, format(len(d), ',')))

    if not apply_it:
        print('\n這是預覽,沒有寫入。確定了就加 --apply。')
        return

    bak = path + BAK
    # ⚠️ 寫之前先擋符號連結。備份路徑是這支自己用「檔名 + .exepebak」組出來的,
    #    那個位置躺著一個指向資料夾外面的連結是完全可能的事,跟過去就寫穿了。
    _refuse_symlink(path, '要修改的執行檔(%s)' % os.path.basename(path))
    _refuse_symlink(bak, '備份檔(%s)' % os.path.basename(bak))

    # os.path.lexists() 而不是 exists():後者對「指向不存在目標」的連結會回
    # False,那樣就會把一個連結當成「還沒有備份」而覆蓋掉它。
    if not os.path.lexists(bak):
        # 先寫同資料夾的唯一暫存檔,fsync + 讀回比對都過了才改名成備份。
        # 中途斷掉留下的是暫存檔,不會有人把半截的東西當成完整備份。
        _atomic_write(bak, d, mode_from=path, tmp_tag='part')
        _copy_times(path, bak)   # 舊版走 copy2,備份會帶著原檔的時間戳,維持一樣
        print('\n已備份:%s' % os.path.basename(bak))
    else:
        print('\n備份已經存在,沿用:%s' % os.path.basename(bak))

    _atomic_write(path, bytes(out), mode_from=path, tmp_tag='new', mark_target=True)

    # 複驗:讀回來逐位元組比對,動到的一定要剛好是計畫裡那些
    back = io.open(path, 'rb').read()
    diff = ([i for i in range(len(d)) if d[i] != back[i]]
            if len(back) == len(d) else [])
    if len(back) != len(d) or len(diff) != changed:
        # ⚠️ 複驗不過就一定要非零結束,不可以只印一行 ❌ 然後照樣回 0。
        #    手上還有剛剛逐位元組驗過的完整備份,所以先自動還原,再把指令印出來。
        print('\n❌ 複驗失敗:寫回去的內容跟預期不同。')
        try:
            _restore_from_backup(bak, path)
            print('   已經自動用備份還原回原狀:%s' % os.path.basename(path))
        except BaseException as e:            # SystemExit 也要接住,不能讓它靜靜跳出去
            print('   自動還原沒有成功(%s)。' % e)
            print('   請手動還原:python3 mvp_exe_pe.py "%s" --restore' % path)
        raise DataError('複驗失敗:寫回去的內容跟預期不同(上面已說明處理結果)。')
    print('已寫入。複驗:改動的位元組剛好是預期那 %d 個,其他一個都沒動 ✅' % changed)
    print('\n進遊戲看看。沒效果或有問題就 --restore。')


# ─────────────────────────────────────────────────────────
#  自我測試:自己造一顆最小的 PE 出來測,不需要遊戲檔
#
#  ⚠️ 測試要**兩個方向都測**:該改的有沒有改到,不該改的有沒有被動到。
#     只測「改得動」的測試,遇到「什麼都改」的 bug 一樣會過。
# ─────────────────────────────────────────────────────────
def _fake_pe():
    """造一顆結構正確的 32 位元 PE:兩個節區、一筆匯入、IAT 目錄留空。"""
    SECT_ALIGN = 0x1000
    d = bytearray(0x4000)
    d[0:2] = b'MZ'
    struct.pack_into('<I', d, 0x3C, 0x80)          # e_lfanew
    pe = 0x80
    d[pe:pe + 4] = b'PE\0\0'
    struct.pack_into('<H', d, pe + 4, 0x014C)      # Machine = i386
    struct.pack_into('<H', d, pe + 6, 2)           # 兩個節區
    struct.pack_into('<H', d, pe + 20, 224)        # Optional Header 大小
    opt = pe + 24
    struct.pack_into('<H', d, opt, 0x010B)         # PE32
    struct.pack_into('<I', d, opt + 92, 16)        # 16 個資料目錄
    sect = opt + 224

    text = {'name': b'.text', 'va': SECT_ALIGN, 'raw': SECT_ALIGN,
            'size': SECT_ALIGN, 'flags': SCN_CODE | SCN_EXECUTE | SCN_READ | SCN_WRITE}
    rdata = {'name': b'.rdata', 'va': SECT_ALIGN * 2, 'raw': SECT_ALIGN * 2,
             'size': SECT_ALIGN * 2, 'flags': SCN_INITIALIZED | SCN_READ | SCN_WRITE}
    for i, s in enumerate((text, rdata)):
        b = sect + i * 40
        d[b:b + len(s['name'])] = s['name']
        struct.pack_into('<IIII', d, b + 8, s['size'], s['va'], s['size'], s['raw'])
        struct.pack_into('<I', d, b + 36, s['flags'])

    # .rdata 裡放一筆匯入描述子,FirstThunk 指到同一節區稍後的位置
    imp_rva = rdata['va']
    thunk_rva = rdata['va'] + 0x100
    struct.pack_into('<IIIII', d, rdata['raw'], 0, 0, 0, 0, thunk_rva)
    struct.pack_into('<IIIII', d, rdata['raw'] + 20, 0, 0, 0, 0, 0)   # 結束
    t = rdata['raw'] + 0x100
    struct.pack_into('<III', d, t, 0x80001111, 0x80002222, 0)         # 兩筆 + 結尾 0
    struct.pack_into('<II', d, opt + 96 + DIR_IMPORT * 8, imp_rva, 40)
    struct.pack_into('<II', d, opt + 96 + DIR_IAT * 8, 0, 0)          # 故意留空
    return bytes(d), thunk_rva


def selftest():
    # ⚠️ -O 會把 assert 整個拿掉。這一支的自我測試雖然走 chk() 不走 assert,
    #    但全站十幾支腳本共用同一條規矩:selftest 一律不在 -O 下跑,免得哪一天
    #    有人加了 assert 就變成假綠而沒人看得出來。
    if sys.flags.optimize:
        print('--selftest 不能在 python -O 下跑:-O 會把 assert 全部拿掉,測試會假綠')
        return 2
    ok = True

    def chk(cond, msg):
        nonlocal ok
        print('   %s %s' % ('✅' if cond else '🔴', msg))
        if not cond:
            ok = False

    print('=== 自我測試 ===\n')
    raw, thunk_rva = _fake_pe()
    d = tempfile.mkdtemp(prefix='exepe_')
    p = os.path.join(d, 'fake.exe')
    io.open(p, 'wb').write(raw)
    # 另外開一個「這個資料夾以外」的地方,專門給【8】符號連結那一項當靶。
    outside_dir = tempfile.mkdtemp(prefix='exepe_outside_')
    outside = os.path.join(outside_dir, 'do_not_touch.bin')
    io.open(outside, 'wb').write(b'DO NOT TOUCH')
    try:
        h = parse_pe(raw)
        secs = read_sections(raw, h)

        print('【1】算得出 IAT 範圍嗎')
        got = compute_iat_range(raw, h, secs)
        chk(got == (thunk_rva, 12),
            '算出 RVA=0x%08X size=%d(預期 0x%08X / 12)'
            % (got[0], got[1], thunk_rva) if got else '算不出來')

        print('\n【2】該收緊的收緊,不該動的不動')
        todo = dict((s['name'], new) for s, new in sections_to_tighten(secs))
        chk(set(todo) == {'.text', '.rdata'}, '挑出 .text 與 .rdata(實際:%s)'
            % '、'.join(sorted(todo)) if todo else '一個都沒挑到')
        chk(all(not (v & SCN_WRITE) for v in todo.values()), '新旗標都不帶可寫')
        chk(all((v & SCN_READ) for v in todo.values()), '讀取權限都留著(沒有多關)')

        print('\n【3】真的改一次,然後複驗')
        apply_changes(p, True, True, True)
        d2 = io.open(p, 'rb').read()
        h2 = parse_pe(d2)
        chk(len(d2) == len(raw), '檔案長度沒變')
        chk(read_dir(d2, h2, DIR_IAT) == (thunk_rva, 12), 'IAT 目錄填好了')
        s2 = dict((s['name'], s['flags']) for s in read_sections(d2, h2))
        chk(not (s2['.text'] & SCN_WRITE), '.text 不再可寫')
        chk(s2['.text'] & SCN_EXECUTE, '.text 還是可執行')
        chk(struct.unpack_from('<I', d2, h2['checksum'])[0] == _recalc_checksum(d2, h2),
            '檢查碼跟著重算了')

        print('\n【4】陰性對照:改過的檔再跑一次,不該再動它')
        before = io.open(p, 'rb').read()
        apply_changes(p, True, True, True)
        chk(io.open(p, 'rb').read() == before, '第二次執行沒有再動任何位元組')

        print('\n【5】還原守衛:半截備份不可以蓋掉正本')
        bak = p + BAK
        io.open(bak, 'wb').write(io.open(bak, 'rb').read()[:200])
        live_before = io.open(p, 'rb').read()
        try:
            _restore_from_backup(bak, p)
        except SystemExit:
            pass
        except Exception:
            pass
        chk(io.open(p, 'rb').read() == live_before, '正本沒有被半截備份蓋掉')

        print('\n【6】還原的正常路徑:備份是好的就要真的蓋回去')
        # ⚠️ 這一項不能省。【5】只證明「壞備份會被擋下」,而那條路在 raise 的
        #    時候就結束了,永遠走不到真正覆蓋的那一行。也就是說,少了這一項的話
        #    「合法還原每一次都崩潰」這種缺陷會躲過整套測試,總判定照樣印 ✅。
        io.open(bak, 'wb').write(raw)                   # 放一份完好的備份回去
        io.open(p, 'wb').write(b'\0' * len(raw))        # 把正本弄成不一樣的內容
        _restore_from_backup(bak, p)
        chk(io.open(p, 'rb').read() == raw, '備份完好時真的還原回來了(逐位元組相同)')

        print('\n【7】還原是原子的:換檔那一步失敗,正本必須原封不動')
        # ⚠️ 這一項是【6】的反面。【6】證明「備份好就還原得回來」,
        #    但那條路徑就算是 shutil.copy2(先截斷再複製)也照樣會過。
        #    真正要證的是**失敗的時候**正本沒有被截成半截。
        io.open(bak, 'wb').write(raw)
        io.open(p, 'wb').write(b'\xEE' * len(raw))
        live7 = io.open(p, 'rb').read()
        real_replace = os.replace

        def _boom(_src, _dst):
            raise OSError(28, '假裝磁碟滿了')

        os.replace = _boom
        try:
            _restore_from_backup(bak, p)
        except BaseException:
            pass
        finally:
            os.replace = real_replace
        chk(io.open(p, 'rb').read() == live7, '換檔失敗時正本一個位元組都沒動')
        chk(len(io.open(p, 'rb').read()) == len(raw), '正本沒有被截短')
        junk7 = [x for x in os.listdir(d) if x.startswith('.fake.exe.')]
        chk(not junk7, '失敗之後沒有留下暫存檔(實際 %d 個)' % len(junk7))

        print('\n【8】符號連結守衛:連結指到資料夾外面就拒絕,不跟過去')
        os.remove(bak)
        os.symlink(outside, bak)                       # 備份的位置放一個連結
        live8 = io.open(p, 'rb').read()
        refused = False
        try:
            _restore_from_backup(bak, p)
        except SystemExit:
            refused = True
        except Exception:
            pass
        chk(refused, '--restore 拒絕了指向外面的備份連結')
        chk(io.open(outside, 'rb').read() == b'DO NOT TOUCH', '資料夾外那個檔沒被動到')
        chk(io.open(p, 'rb').read() == live8, '正本沒有被連結後面的內容蓋掉')

        # 斷掉的連結(指向不存在的檔)也要認得出來。os.path.exists() 對它回 False,
        # 少了 _restore_from_backup 開頭那一道,就會被當成「找不到備份」——
        # 訊息指錯方向,讀者會去找一個其實躺在那裡的連結。
        os.remove(bak)
        os.symlink(os.path.join(outside_dir, 'nope.bin'), bak)
        msg8 = ''
        try:
            _restore_from_backup(bak, p)
        except BaseException as e:
            msg8 = str(e)
        chk('符號連結' in msg8,
            '斷掉的連結也被認出是連結(訊息開頭:%s)' % msg8.split('\n')[0][:34])


        p2 = os.path.join(d, 'fake2.exe')
        io.open(p2, 'wb').write(raw)
        os.symlink(outside, p2 + BAK)                  # --apply 也要擋
        refused2 = False
        try:
            apply_changes(p2, True, True, True)
        except SystemExit:
            refused2 = True
        except Exception:
            pass
        chk(refused2, '--apply 遇到連結備份也拒絕')
        chk(io.open(outside, 'rb').read() == b'DO NOT TOUCH', '外面那個檔還是沒被動到')
        chk(io.open(p2, 'rb').read() == raw, 'fake2.exe 一個位元組都沒被改')

        print('\n【9】寫入是原子的:換檔失敗時不留半截的正本、也不留假備份')
        p3 = os.path.join(d, 'fake3.exe')
        io.open(p3, 'wb').write(raw)
        os.replace = _boom                             # 連備份都換不上去
        try:
            apply_changes(p3, True, True, True)
        except BaseException:
            pass
        finally:
            os.replace = real_replace
        chk(io.open(p3, 'rb').read() == raw, '備份就失敗時,正本原封不動')
        chk(not os.path.lexists(p3 + BAK), '沒有留下一份半截的備份')
        chk(not [x for x in os.listdir(d) if x.startswith('.fake3.exe.')],
            '沒有留下暫存檔')

        p4 = os.path.join(d, 'fake4.exe')
        io.open(p4, 'wb').write(raw)

        def _boom_on_exe(src, dstp):                   # 只讓「換正本」那一步失敗
            if os.path.abspath(dstp) == os.path.abspath(p4):
                raise OSError(28, '假裝磁碟滿了')
            return real_replace(src, dstp)

        os.replace = _boom_on_exe
        try:
            apply_changes(p4, True, True, True)
        except BaseException:
            pass
        finally:
            os.replace = real_replace
        chk(io.open(p4, 'rb').read() == raw, '備份成功、換正本失敗時,正本仍是原樣')
        chk(os.path.isfile(p4 + BAK) and io.open(p4 + BAK, 'rb').read() == raw,
            '備份留下來而且是完整的(還原得回去)')
        chk(not [x for x in os.listdir(d) if x.startswith('.fake4.exe.')],
            '沒有留下暫存檔')

        print('\n【10】Ctrl-C 要說得出實話:換檔做過沒做過分得出來')
        # ⚠️ 兩個方向都要測。只測「做過會記錄」的話,「永遠記錄」的 bug 一樣會過,
        #    那會讓預覽被中斷時謊報「檔案已經改了」。
        p5 = os.path.join(d, 'fake5.exe')
        io.open(p5, 'wb').write(raw)
        _reset_mark()
        apply_changes(p5, True, True, False)           # 只是預覽
        chk(_WROTE_TO_TARGET['path'] is None
            and _WROTE_TO_TARGET['state'] == 'none',
            '預覽沒有記錄(被中斷時才說得出「什麼都沒動到」)')
        apply_changes(p5, True, True, True)            # 真的寫
        chk(_WROTE_TO_TARGET['path'] == p5
            and _WROTE_TO_TARGET['state'] == 'done',
            '真的換過檔就記錄下來(被中斷時才不會謊報沒動到)')
        _reset_mark()

        _atomic_write(os.path.join(d, 'plain.bin'), b'x', tmp_tag='probe')
        chk(_WROTE_TO_TARGET['path'] is None,
            '不是換正本的寫入(例如做備份)不會被記成「已改」')

        print('\n【11】符號連結守衛:正本自己是連結、還原目標是連結,都要拒絕')
        # ⚠️【8】測的是「備份的位置放了一個連結」。這一項補另外兩個寫入目的地:
        #    ① --apply 的正本自己就是連結 ② --restore 要寫回去的目標是連結。
        #    餌指向的是資料夾外面一顆**合法的 PE**,所以不會在解析階段就被擋掉 ——
        #    真正要證的是「守衛擋下來」,不是「檔案看起來不對」。
        outside_exe = os.path.join(outside_dir, 'real_outside.exe')
        io.open(outside_exe, 'wb').write(raw)
        p6 = os.path.join(d, 'fake6.exe')
        os.symlink(outside_exe, p6)
        refused6 = False
        try:
            apply_changes(p6, True, True, True)
        except SystemExit:
            refused6 = True
        except Exception:
            pass
        chk(refused6, '--apply 遇到「正本自己是連結」就拒絕(不跟過去改外面那顆)')
        chk(io.open(outside_exe, 'rb').read() == raw, '連結後面那顆檔一個位元組都沒被改')
        chk(not os.path.lexists(p6 + BAK), '也沒有在連結旁邊留下備份')

        p7 = os.path.join(d, 'fake7.exe')
        os.symlink(outside_exe, p7)
        io.open(p7 + BAK, 'wb').write(raw)             # 備份是好的,問題出在目標
        refused7 = False
        try:
            _restore_from_backup(p7 + BAK, p7)
        except SystemExit:
            refused7 = True
        except Exception:
            pass
        chk(refused7, '--restore 遇到「要寫回去的目標是連結」也拒絕')
        chk(io.open(outside_exe, 'rb').read() == raw, '外面那顆檔還是沒被還原蓋過去')

        print('\n【12】Ctrl-C 不可以說謊:不可中斷區與三態旗標')
        # ⚠️ 這裡**不送真的訊號**。Windows 的 os.kill() 不是送訊號而是直接砍行程,
        #    測試自己會死在那一行。改成直接呼叫「當下裝著的那個處理器」——
        #    測到的是同一段邏輯:_NoInterrupt 有沒有把處理器換上去、有沒有收回來、
        #    有沒有把該丟的 KeyboardInterrupt 留到離開區塊才丟。
        old_sigint = signal.getsignal(signal.SIGINT)
        trail = []
        late = False
        try:
            with _NoInterrupt():
                signal.getsignal(signal.SIGINT)(signal.SIGINT, None)
                trail.append('區塊跑完了')
        except KeyboardInterrupt:
            late = True
        chk(trail == ['區塊跑完了'], '區塊裡的動作沒有被中斷切開(它要先做完)')
        chk(late, '離開區塊之後 KeyboardInterrupt 照樣丟出來(只延後,沒吞掉)')
        chk(signal.getsignal(signal.SIGINT) is old_sigint, '原本的 Ctrl-C 處理器有還回去')

        # 換檔失敗時旗標要收回「沒動到」,不可以卡在「正在換」——
        # 卡住的話,一次失敗的預覽被 Ctrl-C 就會嚇讀者說「可能已經換過去了」。
        p8 = os.path.join(d, 'fake8.exe')
        io.open(p8, 'wb').write(raw)
        _reset_mark()
        os.replace = _boom
        try:
            _atomic_write(p8, b'x' * 32, mark_target=True)
        except BaseException:
            pass
        finally:
            os.replace = real_replace
        chk(_WROTE_TO_TARGET['state'] == 'none',
            '換檔失敗時旗標收回「沒動到」(不會卡在「正在換」嚇人)')
        chk(io.open(p8, 'rb').read() == raw, '換檔失敗,正本仍是原樣')
        _atomic_write(p8, b'x' * 32, mark_target=True)
        chk(_WROTE_TO_TARGET['state'] == 'done' and _WROTE_TO_TARGET['path'] == p8,
            '換檔成功時旗標是「已換」(被中斷才說得出實話)')
        _reset_mark()

    finally:
        shutil.rmtree(d, ignore_errors=True)
        shutil.rmtree(outside_dir, ignore_errors=True)

    print('\n' + '=' * 46)
    print('總判定: %s' % ('✅ 全部通過' if ok else '❌ 有問題'))
    return 0 if ok else 1


def main():
    ap = argparse.ArgumentParser(
        description='把改過的 MVP Baseball 2005 執行檔的 PE 檔頭修回合乎規格。')
    ap.add_argument('exe', nargs='?', help='遊戲執行檔的完整路徑(mvp2005.exe)')
    ap.add_argument('--iat', action='store_true', help='補回 IAT 資料目錄')
    ap.add_argument('--sections', action='store_true', help='收緊節區的寫入權限')
    ap.add_argument('--apply', action='store_true', help='真的寫入(沒加就只是預覽)')
    ap.add_argument('--restore', action='store_true', help='從 %s 還原' % BAK)
    ap.add_argument('--selftest', action='store_true', help='自我測試,不需要遊戲檔')
    a = ap.parse_args()

    if a.selftest:
        return selftest()
    if not a.exe:
        ap.print_help()
        return 2

    # 目標本身是符號連結的話:**要寫的時候一律拒絕,不跟過去**。
    # ⚠️ 舊版是 realpath() 解開之後照樣寫。那等於「你指著 A,我去改 B」——
    #    B 可能根本不在遊戲資料夾裡,而備份(.exepebak)又是照你打的 A 組出來的,
    #    出事的時候還原會找不到對的東西。所以現在改成拒絕,請直接對真正的檔操作。
    # 只讀的模式不受限制(不加參數的檢查、加了 --iat/--sections 但沒 --apply
    # 的預覽)—— 讀不會弄壞任何人的檔案。
    # 只看最後那一個檔;路徑中間的資料夾是連結沒關係(有人把遊戲放在別顆碟)。
    if (a.apply or a.restore) and os.path.islink(a.exe):
        _refuse_symlink(a.exe, '要修改的執行檔(%s)' % os.path.basename(a.exe))

    # ⚠️ 「檔案不存在」不可以擋掉 --restore。最需要還原的情境正是
    #    「執行檔被刪掉 / 被別的東西蓋掉」,那時候備份就躺在旁邊。
    if not a.restore and not os.path.isfile(a.exe):
        print('找不到這個檔:%s' % a.exe)
        return 2
    try:
        if a.restore:
            bak = a.exe + BAK
            # 還原前:備份自己得是一顆執行檔(拿錯檔就擋下來)。
            # 半截備份那一道更關鍵,由 _restore_from_backup() 驗 PE 節區表;
            # 「不存在」與「0 bytes」也留給它講,它的訊息說得更清楚。
            if (os.path.exists(bak) and os.path.getsize(bak) > 0
                    and io.open(bak, 'rb').read(2) != b'MZ'):
                print('這份備份的開頭不是 MZ(不是執行檔),不敢拿它覆蓋 %s。' % a.exe)
                return 1
            _restore_from_backup(bak, a.exe)
            # 還原後:整份逐位元組比對,不是只比開頭也不是只比長度。
            same = (io.open(bak, 'rb').read() == io.open(a.exe, 'rb').read())
            print('已還原:%s' % os.path.basename(a.exe))
            print('複驗:內容與備份%s' % ('相同 ✅' if same else '不同 ❌'))
            if not same:
                print('還原後的內容跟備份對不起來,請手動檢查那兩個檔。')
                return 1
            return 0
        if not (a.iat or a.sections):
            report(a.exe)
            return 0
        apply_changes(a.exe, a.iat, a.sections, a.apply)
        return 0
    except DataError as e:
        print('\n停下來了:%s' % e)
        return 1


if __name__ == '__main__':
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        # ⚠️ 「什麼都沒動到」這句話只有在換檔那一步還沒發生的時候才可以說。
        #    三態各講各的實話,一律以 130 結束(128 + SIGINT),**不可以回 0**。
        _state = _WROTE_TO_TARGET['state']
        _done = _WROTE_TO_TARGET['path']
        if _state == 'none':
            print('\n你按了 Ctrl-C。換檔那一步還沒發生,你的檔案一個位元組都沒有動到。')
        elif _state == 'replacing':
            print('\n你按了 Ctrl-C,而中斷的時候正在替換:%s' % _done)
            print('這個檔可能已經換過去,也可能還沒有。請跟備份比對,或直接還原:')
            print('  python3 mvp_exe_pe.py "%s" --restore' % _done)
        else:
            print('\n你按了 Ctrl-C,但檔案已經換過去了:%s' % _done)
            print('要回到原狀請跑:python3 mvp_exe_pe.py "%s" --restore' % _done)
        sys.exit(130)

# ─────────────────────────────────────────────────────────
#  MIT License
#
#  Copyright (c) 2026 toni
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in
#  all copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
#  THE SOFTWARE.
# ─────────────────────────────────────────────────────────

📌 重點整理

接下來