教學 › 把應援曲指給別的球員

把應援曲指給別的球員

EA 錄了 57 組球員專屬應援曲。這一課教你把其中一首指給你想要的球員 —— 只改一行純文字,完全不動那個 54 MB 的音檔。

⚠️ 這裡原本寫「56 位」,跟 --list 印出來的 57 對不上。 本站把索引檔數過:pchants: 開頭的群組剛安裝好的英文版原版是 57 組、 測試機也是 57 組,而且兩邊的號碼集合完全相同;那 57 組底下共 58 個音檔條目 (0808 那一組有兩首)。56 是舊數字,群組數是 57。 本站另外把剛安裝好的中文版原版也數過,一樣是 57 組、號碼集合相同。 那 57 組裡有一組(pchants:0538)在兩份原版的名冊 data/database/attrib.dat 裡都對不到球員,所以 「應援曲群組」是 57 組、「有專屬應援曲的球員」是 56 位, 兩個數字都對,差在單位。

難度

★☆☆ 只改一行文字

時間

約 5 分鐘

可以還原嗎

可以,一行指令

先講範圍:這一課只做「重新指派」,不換音檔內容

要放自己的聲音,得把它編成 EA 自家的音訊格式。 ⚠️ 2026-08-29 訂正:這裡原本寫「本站還沒有做出那個編碼器」 —— 那個編碼器後來做出來了, 見換掉遊戲的聲音。 本站拿那支腳本跑過這一課講的 chantdat.big:它是編碼器 2、 2 聲道 24000 Hz、裡面 103 段,其中 40 段匯得出來也寫得回去; 另外 63 段有區塊切不出相位,那支腳本會拒絕寫入(對不齊就不寫)。

但這一課本身還是只做重新指派。「換誰在用」跟「換內容」是兩件事: 這一課只改一行純文字、完全不動那 54 MB;要換裡面的聲音走另一課。

另外提醒一件跟技術無關的事:把別人的音樂放進遊戲檔案,跟你自己在家聽是兩回事。 請用自己錄的、有授權的、或已進入公有領域的。

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

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

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

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

  1. 下載 mvp_swap_chant.py(1,945 行,零相依),電腦裡要有 Python 3。下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列,把字刪掉改打 cmd 按 Enter,後面的指令都在開出來的這個視窗打(Mac 開終端機先打 cd ~/MVP_Tools)。
  2. 看遊戲裡有哪些應援曲,會列出 57 首,挑一首你想搬走的

    Windows:

    python -I mvp_swap_chant.py "<遊戲資料夾>" --list

    Mac / Linux:

    python3 -I mvp_swap_chant.py "<遊戲資料夾>" --list
  3. 預覽。會把「哪一行、改成什麼」原文印給你看,不動任何檔案。名字打英文,打長一點避免撞名,也可以直接給四位數的 audioid

    Windows:

    python -I mvp_swap_chant.py "<遊戲資料夾>" --give Chin-Feng 0215

    Mac / Linux:

    python3 -I mvp_swap_chant.py "<遊戲資料夾>" --give Chin-Feng 0215
  4. 確定了才加 --apply 真的改,然後看「複驗」那一行

    Windows:

    python -I mvp_swap_chant.py "<遊戲資料夾>" --give Chin-Feng 0215 --apply

    Mac / Linux:

    python3 -I mvp_swap_chant.py "<遊戲資料夾>" --give Chin-Feng 0215 --apply
  5. 複驗沒過就立刻還原,然後回報。不用回頭翻這一頁 —— 複驗沒過的時候腳本會把整句還原指令印出來,而且是結束碼 2 收場, 不會印個 ❌ 就當作沒事

    Windows:

    python -I mvp_swap_chant.py "<遊戲資料夾>" --restore

    Mac / Linux:

    python3 -I mvp_swap_chant.py "<遊戲資料夾>" --restore

--give 後面是「球員、來源編號」兩個參數。想知道應援曲為什麼會跑到別人身上往下讀;只想複習跳到📌 重點整理。
想換的是音檔內容而不是「誰在用」,那是換掉遊戲的聲音那一課。

照做卡住了?直接跳到❌ 出錯處理(可以把畫面上印出來的那一行貼進那裡的搜尋框);做完對照✅ 成功標準。

應援曲是怎麼接到球員身上的

一共四層,而除了最後那一層,全部是純文字:

data/audio/chants/spch_cht.txt      群組索引  ← 這一課只動這個
    pchants:0004                     群組名,那四位數就是球員的 audioid
      pchant.0004.typ1.1.lg.01.wav  0    群組裡的音檔,後面是群組內部的位移

data/audio/chants/chantdat.off      每個群組在音檔資料裡的「起始,長度」(也是純文字)
data/audio/cd/chants/chantdat.big   真正的音檔,54 MB,EA 自家格式

所以「把應援曲指給別人」就是把群組名那四位數換掉,一行文字的事。

這個結構自己帶著驗算

chantdat.off 有 90 筆,剛好等於 90 個群組。 每一筆的結尾等於下一筆的起點,連續無縫; 而最後一筆的「起始 + 長度」是 56,474,880 —— 剛好等於 chantdat.big 的檔案大小,一個位元組都不差。

這種「格式自己帶著驗算方法」的情況不常見。遇到了要用: 它讓你知道自己有沒有讀懂,不必等進遊戲才發現。

一個好玩的現象:應援曲綁的是號碼,不是人

應援曲的群組名是 pchants:0304 這種四位數, 那是球員資料檔裡的 playerattrib_audioid。 換一份名冊,號碼就會落到別人身上。

本站測試機裝的是一份比較新的名冊,跑 --list 會看到
0304  Shohei Ohtani
0339  Vladimir Guerrero Jr.

那兩首應援曲是 EA 在 2004 年錄的,錄的時候這兩位還沒進大聯盟。 他們只是後來拿到了同一個號碼,就繼承了前人的應援曲。

所以 --list 列出來的名字是「現在誰在用這個號碼」, 不是「這首歌當初為誰而錄」。這一課要換的也是號碼,不是歌。

前置

👉 你要做的事

1

下載腳本

mvp_swap_chant.py(1,945 行,零相依)

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

2

看遊戲裡有哪些應援曲

Windows:

python -I mvp_swap_chant.py "<遊戲資料夾>" --list

Mac / Linux:

python3 -I mvp_swap_chant.py "<遊戲資料夾>" --list

會列出 57 首,每首後面是現在拿著那個號碼的球員。挑一首你想搬走的。

3

預覽

會把「哪一行、改成什麼」原文印給你看,不動任何檔案。

Windows:

python -I mvp_swap_chant.py "<遊戲資料夾>" --give Chin-Feng 0215

Mac / Linux:

python3 -I mvp_swap_chant.py "<遊戲資料夾>" --give Chin-Feng 0215

名字打英文,打長一點避免撞名。也可以直接給四位數的 audioid。

4

確定了才真的改

Windows:

python -I mvp_swap_chant.py "<遊戲資料夾>" --give Chin-Feng 0215 --apply

Mac / Linux:

python3 -I mvp_swap_chant.py "<遊戲資料夾>" --give Chin-Feng 0215 --apply

寫完會自己再讀一次,確認群組數、音檔數沒變,而且沒有出現重複的群組名。

✅ 成功標準

這一課的驗證到哪裡為止,講明白

腳本能驗的是「檔案改對了」:只動了那一行、其他行一個位元組沒變、 群組與音檔的數量不變、沒有重複的群組名、檔案長度符合預期。

腳本不能驗的是「遊戲會不會照這個索引播放」。 那要靠你的耳朵。本站沒有辦法自動測這件事,所以不會寫成「一定會生效」。

❌ 出錯處理

看到什麼怎麼做
can't open file '…mvp_*.py' 命令視窗跟腳本不在同一個資料夾。 打開放著那個 .py 的資料夾(照第 0 課操作六搬過的話就是 MVP_Tools),在網址列打 cmd 按 Enter,用新開的視窗再跑一次(Mac 用 cd 切過去,例如 cd ~/MVP_Tools)。 還是一樣的話,是檔名對不上:瀏覽器可能在檔名後面加了 (1),把它改成跟指令裡一模一樣
索引裡沒有 pchants:NNNN用 --list 看有哪些編號
名字含「X」的有 N 位,請打長一點照它列出來的挑,或直接給四位數
他本來就有專屬應援曲 腳本會停下來,不會製造重複的群組名。換一位還沒有的球員
那些編號在你的名冊裡找不到球員 剛安裝好的原版只有 pchants:0538 這一組對不到球員;其他對不到的,多半是你換過名冊。應援曲還在,只是沒人在用
他的 audioid 是 NNNNN,超過四位數 腳本會停手不寫。這個索引檔的群組名固定四位數(本站量到的三份 spch_cht.txt,90 個群組行每一行都剛好 32 bytes),寫成五位數會讓那一行多一個位元組,遊戲吃不吃得下本站還沒實測。換一位 audioid 是四位數的球員
spch_cht.txt.chantbak 已經存在,但它不是一份可用的備份 那個名字上是 0 bytes 的檔或是一個資料夾。腳本會在寫入之前停手,先把它刪掉或改名,再跑一次
作業系統擋下這個動作 磁碟滿了、檔案被設成唯讀或被鎖定,或那個路徑被別的東西佔住。清出空間、關掉遊戲、解除鎖定、或改用有寫入權限的位置。 接著看它下一行怎麼說你的檔: 說「一個位元組都沒有動到」就是真的沒動(換檔那一步失敗時,它是先把 spch_cht.txt 讀回來比過才這樣說),排除原因後重跑同一行指令就好, 不用另外跑 --restore —— 備份留的是你第一次動手之前的樣子, 改過不只一次的話,還原會連之前成功的改動一起退掉。 說「已經換過了」或「可能已經換過了」,才照它印出來的那一行 --restore。 (2026-09-26 補:在這之前,換檔那一步自己失敗時它一律說「可能已經換過了」叫你還原, 而本站用替身模擬換檔失敗實測三種情況(第一次改、改過之後再改、還原本身), 正本其實都原封不動。)
複驗沒過 立刻 --restore,然後回報。 腳本自己會把整句還原指令印出來(連你的遊戲資料夾一起帶進去), 而且是結束碼 2 收場 —— 它不會印個 ❌ 就當作沒事

一行就能還原

Windows:

python -I mvp_swap_chant.py "<遊戲資料夾>" --restore

Mac / Linux:

python3 -I mvp_swap_chant.py "<遊戲資料夾>" --restore

本站實測:還原之後 SHA-256 與動手前完全相同。

還原本身也是原子的。它不是直接往正本寫, 而是先在同一個資料夾裡寫一個隨機名字的暫存檔,落地之後整份讀回來 跟備份逐位元組對過,最後才一步換上去。所以還原做到一半失敗 (磁碟滿了、外接碟被拔掉、你按了 Ctrl-C),正本還是還原之前那一份, 不會變成半截 —— 舊版是直接覆蓋的,本站實測會讓 6,835 bytes 的索引檔 當場變成 300 bytes。

還原這一頭也有把關:蓋回去之前先把備份當索引檔讀一次(群組數、音檔數、有沒有重複的群組名、結尾是不是完整的一行),蓋回去之後再整份逐位元組跟備份對一次,對不上就不會印成功。索引檔被刪掉或被搬走時也還原得回來 —— 還原不要求那個檔還在。

📋 回報範本

換應援曲 狀態:✅ / ❌
來源編號:
指給誰:
複驗那一行:
進遊戲聽到的:有變 / 沒變 / 還沒試

📌 重點整理

這支腳本在做什麼

不看程式碼也能知道它怎麼跑。它最後做的事只有一件: 把索引檔 spch_cht.txt 裡某一行的群組名, 從 pchants:<來源四位數> 換成 pchants:<目標的 audioid>。 其他每一行的位元組完全不動,那 54 MB 的音檔連開都沒開。 為了這一行,它前面做了三件準備:找到索引檔並整份用位元組讀進來、 把群組行跟音檔行分出來、從名冊 attrib.dat 湊出「號碼對到誰」的對照表。 改完之後不是寫了就算,而是在記憶體裡先驗算三次, 三次都過才動磁碟,動磁碟之前還先留一份備份。

哪一段做什麼為什麼要有它
main() 讀參數,分派到 --list/--give/--restore 什麼開關都不給就跑 --list, 因為那是唯一絕對不寫檔的動作。--give 沒加 --apply 也只是預覽,寫檔永遠要你多打一個字。
chant_paths() 從遊戲資料夾算出索引檔位置,順便驗你給的路徑對不對 要你給資料夾而不是給檔案,是因為指錯檔案很難發現, 但「這個資料夾底下沒有 data/audio/chants/spch_cht.txt」 一句話就講得清楚。
read_index() 整份用位元組讀進來、切成行,並記住這個檔用的是 CRLF 還是 LF 一解碼成文字就得挑一個編碼,挑錯會把不認得的位元組換成問號, 寫回去時那些行就變了。這個檔是遊戲在讀的,不是給人看的, 只要保證「沒被指定要改的行,位元組完全不動」就夠。 換行也照原檔沿用:統一成 LF 會讓每一行都少一個位元組。
parse_groups() 分出哪些行是群組名(pchants:0004 那種),並記下行號 這個檔除了縮排沒有別的結構標記,所以只能用排除法: 音檔那些行一定帶 .wav,剩下的非空行就是群組名。 行號要一起記,因為改的時候是照行號換掉那一行, 不是在整份檔案裡搜尋取代,後者會誤傷長得一樣的別行。
check_index() 數群組數、音檔數,並找出有沒有重複的群組名 這三個數字就是驗算基準。索引裡出現兩個一模一樣的群組名時, 遊戲會用哪一個本站沒有測過;與其賭,不如在寫出去之前停下來。
load_players() 從 attrib.dat 湊出「audioid 對到哪位球員」 名冊只是方便用的:有它才能用名字查人。 讀不到就回一份空的,腳本照樣跑得動,只是顯示不出名字。 欄號是執行時從表頭找的,不寫死,因為不同名冊的欄位順序不一樣; 而且每一格自己帶著欄號,取出來之後可以再對一次帳。
find_player() 用名字的一部分找人,找到剛好一位才算數 一個都沒有就說找不到,撞名就把候選印出來叫你打長一點。 在會改到遊戲檔的工具裡,猜錯一個人比多問一次糟糕得多。
cmd_give() 主戲:換掉那一行,寫出去之前先自己驗算三次 三道驗算合起來的意思是「這次只換掉一個名字, 沒有多出、少掉或錯位任何東西」:群組數與音檔數要跟改之前一樣、 不可以出現重複群組名、檔案總長度要剛好等於 原長度加上新舊名字的長度差。三道都在記憶體裡跑完才動磁碟, 任何一道沒過,遊戲檔連碰都沒被碰到。目標本來就有專屬應援曲會停手,目標的 audioid 超過四位數也會停手。
_atomic_copy() 備份先寫一個隨機的暫用名字,寫完才改名成 .chantbak 暫用名字不可以是猜得到的(例如 .part):遊戲資料夾是別人也放得進東西的地方, 有人先在那個名字上擺一個指到資料夾外面的符號連結, 照著那個名字寫下去就會傷到遊戲資料夾外面的檔。 所以名字是隨機開的(開檔時就要求「這個名字還沒被佔住」,佔住的是符號連結也算), 而且要寫的名字本身是符號連結時一律停手。 中途斷掉時,斷在半路的是那個暫用檔, .chantbak 這個名字上還沒有東西,下一次執行會判定「還沒備份過」 而重新備份。它攔的是 BaseException 不是 Exception, 因為按 Ctrl-C 跟程式自己結束都不算 Exception, 而那正是最容易中斷備份的兩種情況。
_restore_from_backup() 還原之前先擋掉明顯壞掉的備份 半截的備份蓋回去,會把本來好好的遊戲檔吃掉。 它按檔案類型分別驗:BIGF 對檔頭宣告的總長度、 語系檔看最後一條字串的位移有沒有超出檔尾、執行檔看節區指到哪裡; 其餘的走通用地板,備份不得小於要被蓋掉那個檔的一半。 這一課的索引檔是純文字,走的就是最後那一條 —— 但通用地板擋不住「剛好切在一半以上」的半截備份,所以 cmd_restore() 在它之前另外用索引檔自己的格式再驗一次。
cmd_restore() 把備份放回去(用原子的方式),備份本身留著不刪;前後共三段把關 留著才能再還原一次。它只認自己的 .chantbak 後綴, 不去碰別課留在同一台機器上的備份。三段是:蓋回去之前先把備份當索引檔讀一次(群組數、音檔數、重複群組名、結尾是不是完整的一行),再交給 _restore_from_backup() 擋一次,蓋回去之後整份逐位元組跟備份對一次。 蓋回去這一步不是直接往正本寫:先在同一個資料夾裡寫一個隨機名字的暫存檔, 落地、整份讀回來跟備份對過,最後才一步換上去 —— 所以還原做到一半失敗 (磁碟滿了、外接碟被拔掉、按了 Ctrl-C),正本還是還原之前那一份,不會變成半截。 它不要求索引檔還在 —— 那個檔被刪掉或被搬走的時候,正是最需要還原的時候。

--list 一個位元組都不寫,--give 不加 --apply 也只是預覽。做不到的事寫在腳本開頭: 它只會把既有的那一組改指給別人(來源那位球員從此就沒有專屬應援曲了), 不會幫你新增一組,也不動 chantdat.off 與 chantdat.big。 要換的是音檔內容而不是指派的話,那是另一條路, 見換掉遊戲的聲音。 還有一件腳本驗不到:遊戲會不會照這個索引播放。 它只驗得到「檔案改對了」,聲音對不對要靠你的耳朵,本站沒有辦法自動測這件事。

想先確認腳本本身沒壞,可以跑 python3 -I mvp_swap_chant.py --selftest (Windows 打 python)。它不需要遊戲資料夾:在系統暫存資料夾造一份假的遊戲資料夾, 把這一課走得到的每一道安全網各下一個餌驗一次,跑完整個刪掉,不碰你的遊戲。 全過回 0,有一道沒過回 1。

完整原始碼

下面就是你剛才下載的那一支,一個字都沒有不同(本站有自動檢查在守這件事)。

展開 / 收合完整原始碼(1945 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_swap_chant.py — 把某位球員的專屬應援曲指給另一位球員

EA 錄了 57 組球員專屬應援曲。這支腳本可以把其中一首指給你想要的球員,
例如讓陳金鋒用某位大聯盟球員的應援曲。
(57 這個數字是這樣數的:剛安裝好的英文版原版、剛安裝好的中文版原版、
 本站測試機那一份,三份 spch_cht.txt 裡 pchants: 開頭的群組都是 57 組、
 沒有重複,號碼集合完全相同,底下共 58 個音檔條目(0808 那一組有兩首)。
 其中 pchants:0538 在兩份原版的名冊 data/database/attrib.dat 裡都對不到球員,
 所以「有專屬應援曲的球員」是 56 位、「應援曲群組」是 57 組。這一行原本寫
 「56 位球員」,跟本檔下面「這支腳本只動 pchants 那 57 組」那句對不上。)

**只改一個純文字檔**,不動 54 MB 的音檔資料。

應援曲的對應鏈長這樣,除了最後那一層之外全部是純文字:
  data/audio/chants/spch_cht.txt    群組索引(這支腳本只動這個)
    pchants:0004                     群組名,那四位數就是球員的 audioid
      pchant.0004...wav  0           群組裡的音檔,後面是群組內部的位移
  data/audio/chants/chantdat.off    每個群組在音檔資料裡的 起始,長度
  data/audio/cd/chants/chantdat.big 真正的音檔(54 MB,EA 自家格式)

所以「把應援曲指給別人」就是**把群組名的四位數換掉**,一行文字的事。

⚠️ **做不到的事:放進你自己錄的音檔。**
   那需要把聲音編成 EA 自家的音訊格式,本站還沒有做出那個編碼器。
   這支腳本只能在遊戲裡**已經有的**應援曲之間重新指派。

   ⚠️ 2026-08-31 訂正上面那句:編碼器 2026-08-29 做出來了,
      在教學站「換掉遊戲的聲音」那一課。所以「換內容」不再是整條路都不通,
      卡住的只剩「切不出相位的那些段落」。
      但**這一支腳本的範圍沒有變**:它只改一行純文字,不碰那 54 MB 的音檔。
      要換音檔內容請走那一課,不要期待這一支。
      (訂正紀錄留著不刪,是因為「把未解貼在整條路上而不是真正卡住的那一格」
       正是本站踩過的坑,留著提醒下一個人。)

你給它什麼、它產出什麼
--------------------
輸入:**遊戲資料夾**的路徑(裡面看得到 mvp2005.exe 跟 data 那一層)。
      腳本自己往下找 data/audio/chants/spch_cht.txt,不用你指到檔案。
      名冊 data/database/attrib.dat 有就讀、沒有也照跑,差別只是顯示不出球員名字。

輸出:
  · --list:什麼都不寫,只把遊戲裡的球員專屬應援曲印出來給你看。
  · --give 不加 --apply:什麼都不寫,只印出「哪一行會變成什麼樣」。
  · --give 加 --apply:改 spch_cht.txt 裡**一行**的群組名,其餘每一行原封不動;
    動手之前先留一份 spch_cht.txt.chantbak。
  · --restore:把那份備份放回去(備份本身留著不刪)。
  · --selftest:不需要遊戲資料夾。在系統暫存資料夾造一份假的遊戲資料夾,
    把下面每一道安全網各下一個餌驗一次,跑完整個刪掉,不碰你的遊戲
    (2026-09-24 加)。

安全網在哪
---------
· 預設是預覽。沒加 --apply 一個位元組都不寫。
· 索引檔是**用位元組讀、用位元組寫**的,整份沒有解碼成文字再編碼回去,
  所以沒被你動到的行不可能因為編碼轉換而悄悄改變。行尾是 CRLF 還是 LF 也照原樣沿用。
· 目標球員本來就有專屬應援曲的話直接停手,不製造重複的群組名。
· 寫出去之前先自己算一次:群組數、音檔數要跟改之前一樣,不能有重複群組名,
  檔案總長度也要剛好等於「原長度 + 新舊群組名的長度差」。任何一項對不上就中止。
· 寫檔是先寫一個暫存檔再原子改名,中途斷掉不會留下半截的索引檔。
  暫存檔的名字是隨機的(mkstemp),而且開在同一個資料夾裡 ——
  猜得到的名字(以前是 .tmp / .part)會被人先擺一個指到別處的符號連結,
  寫下去就傷到遊戲資料夾外面的檔。要寫的名字是符號連結時一律停手。
· 備份用 _atomic_copy():要嘛完整、要嘛不存在,不會留下半截的 .chantbak。
· 目標球員的 audioid 超過四位數就停手:群組名在這個檔裡是固定寬度的
  (量到的三份 spch_cht.txt,90 個群組行每一行都剛好 32 bytes),
  寫成五位數會讓那一行多一個位元組,遊戲吃不吃得下本站沒有測過。
· 還原前先把備份**當成索引檔讀一次**(群組數、音檔數、有沒有重複的群組名、
  結尾是不是完整的一行),再交給 _restore_from_backup() 擋一次;
  蓋回去之後還會整份逐位元組跟備份對一次,對不上就不印成功。
  免得拿半截備份去蓋掉本來好好的檔。
· 還原**不是**直接往正本寫。先在同一個資料夾寫一個隨機名字的暫存檔,
  落地、對過位元組,最後才原子改名換上去。所以還原做到一半失敗
  (磁碟滿、外接碟拔掉、按了 Ctrl-C),正本還是還原之前那一份,不會變成半截。
· --restore **不要求索引檔還在**:被刪掉、被別的工具搬走的時候正是最需要
  還原的時候,只要 .chantbak 在就還原得回來。
· 按 Ctrl-C 的時候,「你的檔動了沒」那句話是真的。換名跟登記包成一段
  不可中斷的動作(離開那一段訊號照常丟出,結束碼還是 130),
  所以不會發生「磁碟上已經換好了、螢幕上卻說一個位元組都沒動」。
· 「你的檔動了沒」這句話不是只有 Ctrl-C 會講。換名之後才失敗的那些
  (作業系統擋下來、寫完複驗時讀不回來)也照實講,並且把還原指令印出來;
  換名之前就停手的照舊只說停手的理由,那一句一個字都沒有變。
· 換名那一步自己失敗時(2026-09-26 加),先把正本讀回來比:跟這一次動手之前
  一模一樣,就說一個位元組都沒有動到、排除原因後重跑同一行,不叫你 --restore
  (備份是最早那一份,改過第二次的人照做會連第一次一起退掉);讀回來是新的
  那一份就說已經換過了;讀不回來或兩份都不像,才說「可能已經換過了」並給還原指令。
  (名字上不是一般的檔,例如具名管道,它不去開、算讀不回來 —— 開了會卡住。
  2026-09-26 同一天第二次改。)
· 備份已經存在就保留最早那一份,不會被第二次執行覆蓋掉 ——
  但動手之前會先確認那一份還能用(不是 0 bytes、也不是一個資料夾),
  不能用就停手不寫,不會讓你在「沒有還原點」的狀態下改遊戲檔。

做不到的事(除了上面那條音檔)
---------------------------
· 不會幫你新增一組應援曲,只會把既有的那一組改指給別人。
  來源那位球員從此就沒有專屬應援曲了。
· 不驗「遊戲會不會照這個索引播放」。腳本只驗得到「檔案改對了」,
  聲音對不對要靠你的耳朵,本站沒有辦法自動測這件事。
· 不動 chantdat.off,也不動 chantdat.big。群組在音檔資料裡的位置沒有變,
  變的只是「哪個 audioid 對到這一組」。

寫回封裝檔一律用「接到檔尾」的方式:新資料接在檔案最後面,
只改目錄裡那一項的 8 個位元組 + 檔頭的 4 個位元組。
原本的資料一個位元組都不動,所以出錯了也還原得回來。
(上面這一段是本站每一支會動封裝檔的腳本共用的紀律。
 **這一支根本不開封裝檔**:它只改一個純文字檔。寫在這裡是為了讓每一支長得一樣,
 你把兩支擺在一起對照的時候比較好認。)

自包含:整支腳本就是這一個檔,只用 Python 內建模組,不需要安裝任何套件。
**這一支不讀也不寫 PNG**,它只改一個純文字檔。
(2026-09-03 訂正:這一行原本寫「連讀寫 PNG 都是自己做的」,那是從別課的腳本抄過來的
 樣板句,在這一支上不成立,所以把那半句改掉,理由留在這裡。
 在 mvp_swap_chant.py 這個檔上量到:png 只出現在說明文字裡,程式碼一行都沒有。
 它也用不到:要改的 data/audio/chants/spch_cht.txt 是純文字,翻名字用的
 data/database/attrib.dat 也是逗號分隔的純文字,而且是用唯讀模式讀進來的,
 從頭到尾沒有寫回去。
 「不需要安裝任何套件」那半句是對的:本檔開頭 import 的
 argparse / os / re / shutil / signal / struct / sys / tempfile / zlib
 全是 Python 自己就附的模組(--selftest 與它的 -O 守門在函式裡另外 import 了
 contextlib / io / stat / threading,也都是 Python 自己附的;stat 是 2026-09-26 加的)。
 其中 zlib 在本檔一次都沒有用到(用語法樹數本檔的模組用法:zlib 0 次、os 150 次、
 sys 14 次、signal 12 次、struct 11 次、shutil 6 次、re 2 次、argparse 2 次、tempfile 2 次),
 留著只是為了讓本站每一支腳本的檔頭長得一樣。
 (2026-09-05 訂正這幾個數字:那一輪把備份與還原改成原子的,os 從 35 次變 50 次、
  shutil 2→4、sys 4→6,還多了一個 tempfile。數字寫在說明裡就會過期,
  所以附上量法:用 ast 走一遍本檔,數 <模組>.<屬性> 這種用法出現幾次。
  2026-09-06 那一輪把換名包成不可中斷的一段,多了 signal 4 次,其餘沒變;
  2026-09-10 那一輪只改說明文字與 main() 裡一個判斷,九個數字重數一次全部沒變;
  2026-09-24 加了 --selftest,os 50→112、sys 6→12、signal 4→12、shutil 4→6、
  tempfile 1→2,其餘四個沒變;
  2026-09-26 換名失敗時改成讀回來比、備份的暫存檔不再抄鎖定旗標,os 112→131
  (自我測試裡多 17 次、外面多 2 次),shutil 一進一出還是 6,其餘七個沒變;
  同一天第二次改:讀回來比之前先看名字上是不是一般的檔、自我測試加具名管道那兩道,
  os 131→150(自我測試裡多 16 次、外面多 3 次)、sys 12→14(都在自我測試裡),
  其餘七個沒變。)
 真的自己讀寫 PNG 的是「換球員大頭照」「做一張新的球員臉皮」「換掉開機畫面」
 「換球隊隊徽」這四課:site/tutorials/ 底下的 29 支腳本裡,找得到 png_read 與
 png_write 的就只有那四個檔。)

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

import os
import re
import sys
import zlib
import signal
import struct
import shutil
import tempfile
import argparse

# ── 備份的原子性(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 加)。
# 按 Ctrl-C 的時候要靠它才講得出「你的檔動了沒」—— 而那句話必須是真的,
# 不可以一律安慰讀者說「什麼都沒有動到」。
#
# 分三段而不是兩段,是因為 Ctrl-C 有可能剛好落在 os.replace() 那一瞬間:
# 那一刻換檔可能已經完成、也可能還沒,程式自己分不出來。與其猜,不如照實說
# 「可能已經換過」並給還原指令 —— 多還原一次沒有代價,說錯「沒動到」有。
#   0 = 還沒碰過正本(這時才可以說「一個位元組都沒有動到」)
#   1 = 換檔那一步已經下去了,結果不明
#   2 = 換檔確定完成
#   3 = 換檔那一步自己回報失敗,而且讀回來確認正本跟動手之前一模一樣(2026-09-26 加,
#       見 _settle_failed_replace())
#
# 2026-09-06 再加一層:換檔那一段用 _NoInterrupt 包起來(見下面那個類別),
# 所以「1」這一格幾乎不會被看到了 —— 但它留著,因為 _NoInterrupt 在
# 非主執行緒之類的情況下裝不上處理器,那時就退回原本的行為,還是要有人接住。
_REPLACE_STAGE = 0


class _NoInterrupt(object):
    """把「os.replace + 登記」包起來:這段期間收到 Ctrl-C 先記著,離開這段之後再照常丟出。

    為什麼需要它:os.replace() 換完檔、程式還沒把「已經換過」登記下來之前,
    中間隔著一行。Ctrl-C 剛好落在那一行上的話,收尾那句話就會照舊的登記說
    「遊戲檔一個位元組都沒有動到」—— 而磁碟上其實已經換過了。**那句話是假的。**
    這個類別讓那一格縫不存在:登記完才輪得到 KeyboardInterrupt。

    ⚠️ 它**不是**「按了 Ctrl-C 也停不下來」。訊號只是延後幾微秒,
       離開這一段就照常丟出 KeyboardInterrupt,結束碼一樣是 130。
    """

    def __enter__(self):
        self._pending = False
        self._old = None
        try:
            self._old = signal.signal(signal.SIGINT, self._remember)
        except (ValueError, OSError):
            # 非主執行緒等情況裝不上處理器。退回原本的行為,不會比以前更糟
            # (原本就靠 _REPLACE_STAGE 的三段式據實講)。
            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:
            signal.signal(signal.SIGINT, self._old)
        if self._pending and exc_type is None:
            raise KeyboardInterrupt
        return False


def _state_note(gamedir):
    """回一句「你的遊戲檔到底動了沒」。中斷與作業系統錯誤共用這一句。

    只有一個地方決定怎麼講,兩條路徑就不會有一條說「沒動到」、
    另一條說「已經換過」—— 那種自相矛盾正是本站踩過最多次的坑。
    """
    if _REPLACE_STAGE == 0:
        return '遊戲檔一個位元組都沒有動到。'
    if _REPLACE_STAGE == 3:
        # 不寫「不用還原」:--restore 自己換檔失敗也會走到這裡,那時候你要的正是還原。
        return ('換檔那一步沒有成功,但讀回來確認過:spch_cht.txt 跟這一次動手之前一模一樣,\n'
                '  一個位元組都沒有動到。排除上面的原因之後,重跑同一行指令就好。')
    # 1 這一格原本寫「中斷點就落在換檔那一步」。2026-09-26 起換檔自己回報失敗、
    # 讀回來又比不出來的時候也會停在 1,那時沒有人按 Ctrl-C,所以改成兩種都說得通的話。
    return ('⚠️ 遊戲檔%s。想回到原本的樣子:\n'
            '    python3 -I %s "%s" --restore'
            % ('已經換過了' if _REPLACE_STAGE == 2
               else '可能已經換過了(停在換檔那一步,程式分不出換了沒有)',
               os.path.basename(sys.argv[0]), gamedir))


def _peek(path):
    """讀出 path 現在的內容,給 _settle_failed_replace() 比對用(2026-09-26 加)。

    不存在回 None;讀不到(沒有權限、名字上是資料夾之類)回 False。

    ⚠️ 名字上不是一般的檔就不開它,直接回 False(2026-09-26 同一天第二次改)。
       第一版無條件 open():那個名字上是具名管道(FIFO,不需要任何特權就建得出來)時,
       open 會一直等一個永遠不會來的寫入端 —— 本站實測 --restore 就卡在這一行不動,
       而舊版(不讀回來比)會直接把它換掉、還原成功。還原本來就不要求索引檔還是
       正常的檔(見 cmd_restore()),所以這裡不可以讓它卡住。
       回 False 就是「讀不回來」,收尾照舊說分不出來、給還原指令。
    """
    if not os.path.lexists(path):
        return None
    if os.path.islink(path) or not os.path.isfile(path):
        return False
    try:
        with open(path, 'rb') as f:
            return f.read()
    except FileNotFoundError:
        return None
    except OSError:
        return False


def _settle_failed_replace(path, before, after):
    """換檔那一步(os.replace)自己丟了 OSError 之後,讀回來看正本現在是哪一份(2026-09-26 加)。

    在這之前,換名本身失敗時登記停在 1(結果不明),收尾一律說「可能已經換過了
    (中斷點就落在換檔那一步)」並叫你 --restore。把 os.replace 換成丟 PermissionError
    的替身實測三種情況:第一次改指派、已經改過一次之後再改第二次、--restore 本身 ——
    三種正本都原封不動、暫存檔也清掉了,沒有遺失資料,只是那句話不對:
    沒有人按 Ctrl-C,而且第二種照著跑 --restore 會連第一次那個成功的改動一起退掉
    (備份保留的是最早那一份)。所以這裡不猜,讀回來比:
      · 跟動手之前那一份(before)逐位元組相同 → 3:確定沒換成,重跑同一行就好
      · 跟要換上去的那一份(after)相同         → 2:其實換成了,照「已經換過了」講
      · 讀不回來、或兩份都不像                 → 維持 1,照舊給還原指令
    before 是 None 代表動手之前那個名字上沒有檔(還原時索引檔已經被刪掉)。
    """
    global _REPLACE_STAGE
    now = _peek(path)
    if now is False:
        return
    if now == before:
        _REPLACE_STAGE = 3
    elif now == after:
        _REPLACE_STAGE = 2


def _no_symlink(path, what):
    """path 這個名字是符號連結就停手,不要跟著它寫到資料夾外面去。

    ⚠️ 不可以用 os.path.exists() 判斷:它對「指向不存在目標的符號連結」
       回的是 False —— 那個連結明明在那裡,卻完全看不到。
       要看名字本身,就得用 os.path.islink / os.path.lexists。

    為什麼要擋:遊戲資料夾是別人(模組包、解壓工具、共用電腦上的另一個人)
    也放得進東西的地方,有人先在那裡擺一個指到別處的連結。

    ⚠️ 2026-09-10 訂正這一段。原本寫的是「我們照著那個名字寫下去,受害的是
       資料夾外面那個檔」—— 那是**舊版**(用 open(那個名字, 'wb') 直接寫)才會
       發生的事。現在寫入一律是「mkstemp 暫存檔 → os.replace」,而 os.replace
       換掉的是**那個名字本身**,不會跟著連結去動它指到的檔。
       把守門拆掉重跑實測(本檔 --apply,索引檔擺成指向資料夾外一份索引的連結):
       exit 0、資料夾外那份**一個位元組都沒被改**,遊戲資料夾裡那個名字
       從連結變成 6,835 bytes 的實體檔。所以原本那句話講不出證據,改掉。

    量得到的後果是另外兩種,而且兩種都不出聲:
      · **索引檔**的名字被連結佔住:改動落在那個名字上 ——
        連結被換成一個實體檔,你真正在用的那一份(連結指到的)沒有收到修改。
        備份**有**做,但那是連結指到的那份的複本、放在遊戲資料夾裡,
        所以資料夾看起來像是「改好也備份好了」,其實你的索引一個位元組都沒變。
        (實測:守門拆掉後 exit 0,資料夾裡多出 spch_cht.txt 6,835 bytes 實體檔
         與 .chantbak,連結指到的那份 sha 完全沒動。)
      · **備份**的名字被連結佔住:工具會判定「備份已存在,保留最早那一份」
        然後照樣改遊戲檔 —— 你的還原點就變成資料夾外面那個檔。
        (實測:守門拆掉後 exit 0、遊戲檔真的被改,而還原點是一個 29 bytes
         的無關檔案;之後真的 --restore,擋下來的是 cmd_restore() 那一道
         「備份看起來被截斷了(群組 1 個 · 音檔 0 個)」,exit 2 ——
         等於改完了卻沒有還原點。
         ⚠️ 這一句原本寫成被「備份不到一半」那一道擋下來,是我推的沒有量;
            實際跑一次才發現是前面那一道先攔住。留著這行提醒下一個人:
            「哪一道守門先攔」也要量,不要照著自己以為的順序寫。)
    """
    if os.path.islink(path):
        raise SystemExit(
            '%s是一個符號連結:%s\n'
            '  → 它指到 %s\n'
            '  跟著這個名字做下去,動到的不會是它指到的那個檔;\n'
            '  備份的名字被連結佔住時,你的還原點會變成資料夾外面那個檔。\n'
            '  所以這裡停手,什麼都不寫。請把那個連結刪掉或改名,再跑一次。'
            % (what, path, os.path.realpath(path)))


def _mkstemp_beside(path, tag):
    """在 path 所在的那個資料夾裡開一個**別人搶不到的**暫存檔,回傳 (fd, 路徑)。

    ⚠️ 為什麼不用 path + '.part' 這種猜得到的名字(2026-09-05 稽核抓到):
       有人先在那個名字上放一個指到資料夾外面的符號連結,
       shutil.copy2() 或 open(那個名字, 'wb') 會**跟著連結**
       把外面那個檔截成 0 再寫進去 —— 後面的 os.replace() 雖然只換掉連結本身,
       但傷害在那之前就造成了。實測(2026-09-05,本課的舊版):
       先擺 spch_cht.txt.chantbak.part → 外面一個 29 bytes 的檔被寫成 6,835 bytes;
       改擺 spch_cht.txt.tmp → 另一個 15 bytes 的檔一樣被寫成 6,835 bytes。

    mkstemp 是用 O_CREAT|O_EXCL 開檔的:名字已經被佔住(佔住的是符號連結也算)
    就直接失敗,不會跟過去。名字本身也是隨機的,先擺也擺不中。

    暫存檔開在**跟目的檔同一個資料夾**,不是系統的暫存區:
    os.replace() 只有在同一個檔案系統上才是原子的。
    """
    d = os.path.dirname(os.path.abspath(path)) or '.'
    return tempfile.mkstemp(dir=d, prefix='.%s.%s-' % (os.path.basename(path), tag))


def _drop(tmp):
    """把還沒扶正的暫存檔清掉。用 lexists 不用 exists,理由同 _no_symlink()。"""
    try:
        if os.path.lexists(tmp):
            os.remove(tmp)
    except OSError:
        pass


def _atomic_copy(src, dst):
    """備份要嘛完整、要嘛不存在 —— 中間狀態不會留在 dst 這個名字上。

    ⚠️ 本站有些腳本用 pathlib.Path 存路徑,有些用字串。
       2026-08-29 第一版寫成 dst + '.part',在 Path 上直接 TypeError,
       等於所有備份都失敗 —— 而且「半截備份被擋下來」那個測試照樣是綠的。
       是陰性對照(先證明正常流程真的會產生備份)抓到的。

    ⚠️ 2026-09-05 再改一次:暫存檔的名字從固定的 dst + '.part'
       改成 _mkstemp_beside() 開出來的隨機名字(理由寫在那個函式裡)。
    """
    # 先統一成字串路徑,這樣下面接後綴才不會因為傳進來的是 Path 而爆掉。
    src, dst = os.fspath(src), os.fspath(dst)
    _no_symlink(dst, '備份檔')
    fd, part = _mkstemp_beside(dst, 'part')
    try:
        # 關鍵在「先寫一個別的名字,寫完才改名」。
        # 中途斷掉時,斷在半路的是那個隨機名字的檔,dst 這個名字上還沒有東西,
        # 下一次執行就會判定「還沒備份過」而重新備份,不會誤用半截的。
        with os.fdopen(fd, 'wb') as fo:
            with open(src, 'rb') as fi:
                shutil.copyfileobj(fi, fo)
            fo.flush()
            os.fsync(fo.fileno())   # 真的落到碟上,不是還躺在快取裡
        # 權限與時間戳照原檔,不要留下 mkstemp 的 0600。
        # ⚠️ 2026-09-26 起不用 shutil.copystat:它在 macOS 上連「已鎖定」旗標(Finder
        #    資訊視窗那一格,uchg)一起抄。原檔被鎖定時暫存檔一抄完也被鎖住,換名被擋、
        #    _drop() 也刪不掉,遊戲資料夾裡留下一個刪不掉的 .part 檔(本站實測)。
        #    時間戳先抄、權限後抄,跟 copystat 自己的順序一樣。
        st = os.stat(src)
        os.utime(part, ns=(st.st_atime_ns, st.st_mtime_ns))
        shutil.copymode(src, part)
        os.replace(part, dst)       # os.replace 是原子的
    # 攔 BaseException 不是攔 Exception:Ctrl-C(KeyboardInterrupt)
    # 跟 SystemExit 都不是 Exception 的子類,而那正是最容易中斷備份的兩種情況。
    except BaseException:
        _drop(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)
    # 先看名字本身是不是符號連結,再問「它在不在」。
    # 順序反過來的話,指向不存在目標的死連結會被 exists() 說成「找不到備份」,
    # 讀者就永遠查不出真正的原因(2026-09-05 稽核)。
    _no_symlink(bak, '備份檔')
    if not os.path.lexists(bak):
        raise SystemExit('找不到備份:%s' % bak)
    # 第 1 道:0 bytes。最常見的半截備份就長這樣。
    n = os.path.getsize(bak)
    if n == 0:
        raise SystemExit(
            '備份是 0 bytes(多半是上次備份到一半被中斷),不敢拿它覆蓋 %s。' % dst)
    with open(bak, 'rb') as _f:
        head = _f.read(8)

    def _stop(why):
        """任何一道檢查沒過就走這裡:印出**哪裡不對**,然後停,絕不還原。

        訊息裡一定帶上「why」那一句具體的理由,而不是只說「備份壞了」。
        知道是哪個欄位對不上,你才判斷得出來要不要改用另一份備份。
        """
        raise SystemExit(
            '這份備份是壞的,不敢拿它覆蓋 %s。\n'
            '  %s\n'
            '  多半是備份途中被中斷(磁碟滿、外接碟拔掉、按了 Ctrl-C)。\n'
            '  請改用你自己另外留的那一份備份。' % (dst, why))

    # 第 2 道:BIGF 封裝檔。檔頭 +0x04 那 4 個位元組是「整個檔案應該有多大」,
    # 拿它跟實際大小對。被截斷過就一定對不上。
    # 兩種位元組順序都接受,因為這個欄位在不同的 .big 裡真的兩種都遇得到。
    if len(head) == 8 and head[:4] == b'BIGF':
        le = struct.unpack('<I', head[4:8])[0]
        be = struct.unpack('>I', head[4:8])[0]
        if le != n and be != n:
            _stop('檔頭說它應該是 %d bytes(或 %d),實際只有 %d bytes。' % (le, be, n))
        return _do_copy(bak, dst)

    # 第 3 道:語系檔(.LOC)。檔頭 +0x10 指到字串區 LOCL,
    # LOCL 裡面是「幾條字串」加上一張位移表。最後一條字串的位移最大,
    # 所以檔案被截斷時,它一定會指到檔案結尾外面去。這一道就是在抓那個。
    if len(head) >= 4 and head[:4] == b'LOCH':
        try:
            d = open(bak, 'rb').read()
            L = struct.unpack('<I', d[16:20])[0]
            if L + 16 > n or d[L:L + 4] != b'LOCL':
                _stop('語系檔的字串區(LOCL)應該在位移 %d,那裡不是 LOCL。' % L)
            lcnt = struct.unpack('<I', d[L + 12:L + 16])[0]
            if lcnt <= 0 or L + 16 + lcnt * 4 > n:
                _stop('語系檔的位移表被截斷了(宣告 %d 條)。' % lcnt)
            last = struct.unpack('<I', d[L + 16 + (lcnt - 1) * 4:L + 20 + (lcnt - 1) * 4])[0]
            if L + last >= n:
                _stop('語系檔最後一條字串在位移 %d,超出檔案結尾(%d bytes)。'
                      % (L + last, n))
        except SystemExit:
            raise
        except (struct.error, IndexError):
            _stop('讀不出語系檔的結構,它壞了。')

    # 第 4 道:Windows 執行檔。開頭是 'MZ',+0x3C 那 4 個位元組指到 PE 檔頭,
    # PE 檔頭後面是節區表,每個節區記著「我在檔案裡從哪裡開始、有多長」。
    # 把所有節區的結尾取最大值,那就是這個檔至少要有多大。
    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):
    """真正把備份放回去。備份先過了它該過的那幾道檢查才會走到這裡。

    哪幾道要看它是什麼類型的檔:BIGF 走檔頭長度那一道,
    語系檔與執行檔各走自己那一道再加通用地板,其餘的只走通用地板。

    抽成一個函式,是為了讓「檢查」跟「覆蓋」只有這一個交會點:
    往後要再加一道檢查,不會有哪一條路徑漏掉。

    ── 2026-09-05 改成原子還原(上線前資安稽核抓到的真漏洞)──────────
    原本這裡是一句 shutil.copy2(bak, dst)。copy2 的做法是
    **先把 dst 開成 'wb'(當場截成 0 bytes)再一段一段寫進去** ——
    中途磁碟滿、外接碟拔掉、按了 Ctrl-C,遊戲正本就停在 0 bytes 或半截。
    而按 --restore 的人手上多半就只剩這一份遊戲檔了,這是最不該賭的一步。

    實測(2026-09-05,本課的舊版):讓覆蓋動作寫了 300 bytes 之後失敗,
    6,835 bytes 的索引檔當場變成 300 bytes。

    現在的順序是:
      1. 目的檔與備份都不可以是符號連結(理由見 _no_symlink():
         動到的不會是它指到的那個檔,而還原點可能變成資料夾外面的檔案)
      2. 在**目的檔那個資料夾裡**開一個隨機名字的暫存檔,寫進去
      3. flush + fsync,真的落到碟上
      4. 權限照正本原本的樣子(正本不在就照備份的)
      5. 把暫存檔**整份讀回來**跟備份逐位元組對一次
      6. 全對了才 os.replace() 換上去 —— 這一步是原子的
    任何一步失敗就把暫存檔刪掉,正本從頭到尾沒有被開過寫入,原封不動。
    """
    global _REPLACE_STAGE
    bak, dst = os.fspath(bak), os.fspath(dst)
    _no_symlink(dst, '要還原的目標檔')
    _no_symlink(bak, '備份檔')
    with open(bak, 'rb') as f:
        want = f.read()
    before = _peek(dst)     # 換檔失敗時拿來比「正本到底動了沒」(2026-09-26 加)
    fd, tmp = _mkstemp_beside(dst, 'restore')
    try:
        with os.fdopen(fd, 'wb') as fo:
            fo.write(want)
            fo.flush()
            os.fsync(fo.fileno())
        # mkstemp 開出來的是 0600。正本原本是什麼權限就照著設回去,
        # 免得還原完之後遊戲(或另一個帳號)反而讀不到它。
        shutil.copymode(dst if os.path.isfile(dst) else bak, tmp)
        # 換上去之前先自己讀回來對一次。這裡對不上就代表根本沒寫成功,
        # 而此時正本還是原本那一份 —— 這正是把比對放在 os.replace **之前**的原因。
        with open(tmp, 'rb') as f:
            got = f.read()
        if got != want:
            raise SystemExit(
                '寫出來的內容跟備份對不起來(備份 %d bytes、寫出 %d bytes),\n'
                '  所以沒有動 %s —— 它還是還原之前那一份。\n'
                '  請確認磁碟空間與這個資料夾的寫入權限。' % (len(want), len(got), dst))
        _REPLACE_STAGE = 1      # 先標記再換,不要在「已經換了但還沒標記」的縫裡被中斷
        # 換名 + 登記包成不可中斷的一段:Ctrl-C 落在這兩行之間時先記著,
        # 等登記做完才丟出來,收尾看到的登記一定跟磁碟上的狀態一致。
        with _NoInterrupt():
            os.replace(tmp, dst)
            _REPLACE_STAGE = 2
    except BaseException as e:
        _drop(tmp)
        # 換檔那一步自己丟 OSError:不要停在「結果不明」,讀回來比(2026-09-26 加)
        if _REPLACE_STAGE == 1 and isinstance(e, OSError):
            _settle_failed_replace(dst, before, want)
        raise




try:
    sys.stdout.reconfigure(encoding='utf-8')
    sys.stderr.reconfigure(encoding='utf-8')
except Exception:
    pass


class DataError(Exception):
    """檔案內容跟預期不符。一律當成「停下來問人」,不要猜。"""


# 備份用自己的後綴,不跟別課共用。--restore 只認這個後綴,
# 所以不會去碰別課(改能力值、改介面文字那些)留在同一台機器上的備份。
BACKUP_SUFFIX = '.chantbak'
# 索引檔實際是 193 行(90 個群組 + 103 個音檔)。這個上限只是壞檔防護:
# 給你的路徑指到別的東西時,寧可停下來問人,也不要開始猜。
MAX_LINES = 5000


# ─────────────────────────────────────────────────────────
#  應援曲索引 spch_cht.txt
#
#  格式:一行群組名,底下每個音檔一行(檔名 + 群組內部位移)。
#  群組有三種:有名字的(tomahawk 那類)、tchants:NN(球隊隊呼)、
#  pchants:NNNN(某位球員專屬)。這支腳本只動 pchants 那 57 組。
# ─────────────────────────────────────────────────────────
def chant_paths(gamedir):
    """從遊戲資料夾算出索引檔的位置,順便當作「你給的路徑對不對」的檢查。

    要你給資料夾而不是給檔案,是因為指錯檔案很難發現,
    但「這個資料夾底下沒有 data/audio/chants/spch_cht.txt」一句話就講得清楚。
    """
    idx = os.path.join(gamedir, 'data', 'audio', 'chants', 'spch_cht.txt')
    if not os.path.isfile(idx):
        raise DataError('找不到 %s\n'
                        '  請確認你給的是遊戲資料夾(裡面看得到 mvp2005.exe 跟 data)' % idx)
    return idx


def read_index(path):
    """回傳三樣:(原始 bytes, 每行的 bytes 清單, 這個檔用的換行位元組)。

    刻意不解碼整份,寫回時才能保證其他行一個位元組都不動;
    換行也要一起帶出去,因為寫回時得用**原本那一種**接回來。

    為什麼一路用 bytes 不用字串:一解碼就得挑一個編碼,挑錯會把不認得的位元組
    換成問號,再寫回去時那些行就變了。這個檔是遊戲在讀的,不是給人看的,
    只要保證「沒被指定要改的行,位元組完全不動」就好,不需要知道它們是什麼文字。
    """
    try:
        raw = open(path, 'rb').read()
    except OSError as e:
        raise DataError('讀不到 %s:%s' % (path, e))
    # 換行照原檔沿用。這個檔是 Windows 遊戲的資料,通常是 CRLF;
    # 統一換成 LF 會讓每一行都少一個位元組,整個檔就變了。
    nl = b'\r\n' if b'\r\n' in raw else b'\n'
    lines = raw.split(nl)
    if len(lines) > MAX_LINES:
        raise DataError('%s 有 %d 行,遠多於預期的 193 行 —— 不敢動' % (os.path.basename(path), len(lines)))
    return raw, lines, nl


def parse_groups(lines):
    """回傳 [(行號, 群組名)]。群組行的特徵:不含 .wav,而且不是空行。

    這個檔沒有縮排以外的結構標記,所以判斷方式只能是「排除法」:
    音檔那些行一定帶 .wav,剩下的非空行就是群組名。

    群組名共有三種寫法:
      · 有名字的(tomahawk 那類通用的加油聲)
      · tchants:NN    球隊隊呼
      · pchants:NNNN  某位球員專屬,那四位數就是他的 audioid
    這支腳本只動第三種。

    行號要一起回傳,因為改的時候是「照行號換掉那一行」,
    不是「在整份檔案裡搜尋取代」。後者會誤傷剛好長得一樣的別行。
    """
    out = []
    for i, l in enumerate(lines):
        s = l.strip()
        if not s or b'.wav' in l:
            continue
        out.append((i, s.decode('latin-1')))
    return out


def check_index(lines):
    """一致性檢查:群組數、音檔數、有沒有重複的群組名。

    這三個數字是這支腳本的驗算基準。改之前量一次、改之後再量一次,
    群組數與音檔數必須完全一樣(這一課只換名字,不增不減),
    而且不可以出現重複的群組名。

    為什麼盯著重複:索引裡出現兩個一模一樣的群組名時,
    遊戲會用哪一個**本站沒有測過**。與其賭,不如在寫出去之前就停下來。
    """
    groups = parse_groups(lines)
    files = [l for l in lines if b'.wav' in l]
    names = [g[1] for g in groups]
    dup = sorted({n for n in names if names.count(n) > 1})
    return len(groups), len(files), dup


# ─────────────────────────────────────────────────────────
#  名單:audioid ←→ 球員
# ─────────────────────────────────────────────────────────
# attrib.dat 的欄位不是固定順序的,不同名冊會不一樣,所以欄號要**執行時去表頭找**,
# 不可以寫死。這裡寫死的只有「欄位的名字」。
AUDIOID_NAME = 'playerattrib_audioid'   # 應援曲群組名後面那四位數,對的就是這一欄
NAME_FIRST = 0                          # 名
NAME_LAST = 1                           # 姓


def load_players(gamedir):
    """回傳 {audioid: '名 姓'}(只有一邊有字的就只有那一邊)。
    讀不到名冊就回空的,腳本照樣能跑(只是顯示不了名字)。

    名冊只是**方便**用的:有它才能用名字查人、才能在清單上顯示誰在用哪首應援曲。
    真正要改的東西完全不需要它,所以這裡一律「讀不到就回空的」,不拋錯。
    換過名冊、名冊格式不一樣、根本沒有 attrib.dat,都不該讓你連 --list 都跑不了。

    attrib.dat 是逗號分隔的純文字,行尾 CRLF。第一行是表頭,
    每一格長成「<欄號> <欄位名>」;之後每一行是一位球員,每一格長成「<欄號> <值>」。
    每一格自己帶著欄號,是這個格式最好用的地方:可以逐格自我對帳。
    """
    path = os.path.join(gamedir, 'data', 'database', 'attrib.dat')
    if not os.path.isfile(path):
        return {}
    raw = open(path, 'rb').read()
    lines = raw.split(b'\r\n')
    # 先讀表頭,把「欄位名 → 欄號」建起來。欄號不寫死就是靠這一步。
    names = {}
    for c in lines[0].split(b','):
        m = re.match(rb'^\s*(\d+) (\w+)', c)
        if m:
            names[m.group(2).decode('latin-1')] = int(m.group(1))
    # 這份名冊沒有 audioid 這一欄的話,再往下讀也拼不出對照表,直接回空的。
    if AUDIOID_NAME not in names:
        return {}
    fld = names[AUDIOID_NAME]

    def cell(l, f):
        """取出這一行第 f 欄的值;對不上就回 None,不猜。

        兩件事讀者看不出來:
        1. 第 f 欄實際落在切開後的第 f+1 格。
           資料列的第 0 格不是欄位,是一串識別碼(長得像 0f58f3c1b)。
           本站測試機那份是表頭 47 格、資料列 48 格,差的就是它;
           你的名冊欄位數可能不一樣,但「差一格」這件事是一樣的。
        2. 每一格自己帶著欄號,所以取出來之後再對一次
           「你身上寫的欄號是不是 f」。對不上代表這一行的格數跟表頭不一致
           (欄位裡有逗號、行壞掉之類),這時候回 None 比回一個錯的值好。
        """
        cs = l.split(b',')
        i = f + 1
        if i >= len(cs):
            return None
        m = re.match(rb'^(\d+) ?(.*)$', cs[i].strip())
        return m.group(2).decode('latin-1') if m and int(m.group(1)) == f else None

    # audioid 取得到、而且名跟姓**至少有一邊**有字才收。用 setdefault:
    # 同一個 audioid 被多位球員共用時,留先出現的那一位,不讓後面的蓋掉。
    out = {}
    for l in lines[1:]:
        if not l.strip():
            continue
        a = cell(l, fld)
        f_, s_ = cell(l, NAME_FIRST), cell(l, NAME_LAST)
        # 只有名、或只有姓的球員也要收。兩份原版名冊裡的 audioid 1263 就是
        # 「名字欄空白、姓氏欄寫 Ichiro」,而 1263 正是 57 組應援曲的其中一組;
        # 原本要求兩欄都有字,他整列被丟掉,--list 少列一個人、--give Ichiro
        # 還會回「名冊裡找不到」。兩欄都空的那種佔位列才丟掉(本站測試機那份
        # 名冊有 25 列是那樣,audioid 全是 0)。
        if a and a.isdigit() and (f_ or s_):
            out.setdefault(int(a), ('%s %s' % (f_ or '', s_ or '')).strip())
    return out


# ─────────────────────────────────────────────────────────
#  各種動作
# ─────────────────────────────────────────────────────────
def cmd_list(gamedir):
    """把遊戲裡的球員專屬應援曲列出來。**一個位元組都不寫。**

    這是你第一個該下的指令:先看清楚有哪些編號可以拿來當來源,
    再決定要把哪一首指給誰。
    """
    idx = chant_paths(gamedir)
    raw, lines, nl = read_index(idx)
    g, f, dup = check_index(lines)
    players = load_players(gamedir)
    print('  索引 %s' % idx)
    print('  群組 %d 個 · 音檔 %d 個%s' % (g, f, ('  ⚠️ 有重複群組名:%s' % '、'.join(dup)) if dup else ''))
    print()
    # 只挑 pchants: 開頭的。tchants:(隊呼)與有名字的通用加油聲不在這一課的範圍。
    # 四位數就拿去名冊裡查是誰在用;查不到就留空,由下面統一講清楚原因。
    rows = []
    for i, name in parse_groups(lines):
        if not name.startswith('pchants:'):
            continue
        num = name.split(':', 1)[1]
        who = players.get(int(num), '') if num.isdigit() else ''
        rows.append((num, who))
    print('  球員專屬應援曲 %d 首:' % len(rows))
    unknown = 0
    for num, who in rows:
        if who:
            print('    %s  %s' % (num, who))
        else:
            unknown += 1
    if unknown:
        print('    ...另外 %d 個編號在你的名冊裡找不到對應的球員' % unknown)
        print('       (名冊換過就會這樣,不是壞掉。那些應援曲還在,只是沒有人在用)')
    print()
    print('  想把其中一首指給別人:')
    if rows:
        print('    python3 -I %s "<遊戲資料夾>" --give <球員名字> %s'
              % (os.path.basename(sys.argv[0]), rows[0][0]))


def find_player(gamedir, keyword):
    """用名字的一部分去名冊裡找人,回傳 (audioid, 全名)。

    找到剛好一位才算數。這一點是刻意的:
      · 一個都沒有 → 直接說找不到,不做模糊比對去猜你要誰
      · 找到好幾位 → 把候選印出來叫你打長一點,或者直接給四位數
    在「會改到遊戲檔」的工具裡,猜錯一個人比多問一次糟糕得多。
    """
    players = load_players(gamedir)
    if not players:
        raise DataError('讀不到名冊,沒辦法用名字查。請直接給 audioid 四位數。')
    key = keyword.lower()
    hits = [(a, n) for a, n in players.items() if key in n.lower()]
    if not hits:
        raise DataError('名冊裡找不到名字含「%s」的球員。' % keyword)
    if len(hits) > 1:
        msg = '\n'.join('    %s  audioid %d' % (n, a) for a, n in sorted(hits, key=lambda x: x[1])[:12])
        raise DataError('名字含「%s」的有 %d 位,請打長一點:\n%s' % (keyword, len(hits), msg))
    return hits[0]


def cmd_give(gamedir, who, source_num, apply_it):
    """把某一首既有的應援曲改指給另一位球員。這一支的主戲都在這裡。

    apply_it 為假時整段只印不寫,而且印的就是待會兒真的要做的那一件事,
    不是另外寫一段「大概會像這樣」。

    做的事只有一句:把索引檔裡某一行的群組名,從 pchants:<來源四位數>
    換成 pchants:<目標的 audioid>。其他每一行的位元組完全不動。
    """
    idx = chant_paths(gamedir)
    raw, lines, nl = read_index(idx)
    players = load_players(gamedir)

    # 目標可以直接給四位數,也可以給名字。給名字就走名冊查,查不到或撞名會停下來。
    if who.isdigit():
        target_id, target_name = int(who), players.get(int(who), '(名冊裡查不到)')
    else:
        target_id, target_name = find_player(gamedir, who)

    # 群組名裡的編號固定四位數(不足補 0),所以打 4 跟打 0004 都收。
    src = source_num.zfill(4)
    hit = [(i, n) for i, n in parse_groups(lines) if n == 'pchants:%s' % src]
    if not hit:
        raise DataError('索引裡沒有 pchants:%s。用 --list 看有哪些。' % src)
    line_no, old_name = hit[0]

    # 目標本來就有專屬應援曲的話,改下去索引裡就會有兩個一樣的群組名。
    # 先查出來,下面直接停手。
    already = [(i, n) for i, n in parse_groups(lines) if n == 'pchants:%04d' % target_id]
    print('  來源應援曲  pchants:%s%s' % (src, ('  (原本是 %s 的)' % players[int(src)]) if int(src) in players else ''))
    print('  要指給      %s  audioid %04d' % (target_name, target_id))
    # 這一道要排在「本來就有專屬應援曲」前面:來源跟目標是同一個編號時,
    # 目標當然「本來就有」那一組,排在後面的話永遠輪不到它,
    # 讀者看到的會是下面那句「會出現兩個一樣的群組名」—— 而同一行換成同一個名字
    # 並不會多出一個。(2026-09-24 對調;在那之前這一道走不到。)
    if int(src) == target_id:
        raise DataError('來源跟目標是同一個編號,不用改。')
    if already:
        print('  ⚠️ %s 本來就有專屬應援曲(pchants:%04d)。' % (target_name, target_id))
        print('     照做的話索引裡會出現兩個一樣的群組名,遊戲會用哪一個本站沒有測過。')
        raise DataError('為了不製造重複的群組名,這裡停下來。\n'
                        '  想換的話請挑一位還沒有專屬應援曲的球員。')
    # 群組名在這個檔裡是固定寬度的:本站量到的三份 spch_cht.txt(英文版原版、
    # 中文版原版、測試機那一份),90 個群組行**每一行都剛好 32 bytes**。
    # audioid 超過四位數會寫出 33 bytes 的一行,破壞這個檔唯一看得見的結構,
    # 而遊戲吃不吃得下本站沒有測過 —— 所以停手,不要拿讀者的遊戲檔去賭。
    # (本站測試機那份名冊裡有 149 位球員的 audioid 是五位數,最大 25028;
    #  兩份原版名冊則是 0 位,最大 2665。)
    if target_id > 9999:
        raise DataError('%s 的 audioid 是 %d,超過四位數。\n'
                        '  這個索引檔的群組名固定四位數,寫成五位數會讓那一行多一個位元組,\n'
                        '  遊戲吃不吃得下本站沒有測過,所以這裡停下來。'
                        % (target_name, target_id))

    new_line = b'pchants:%04d' % target_id
    print()
    print('  會改的就是這一行(第 %d 行):' % (line_no + 1))
    print('    %s' % lines[line_no].decode('latin-1'))
    print('    ↓')
    print('    %s' % new_line.decode('latin-1'))
    # 檔案結尾有換行時,raw.split(nl) 會多回一個空字串,那不是一行;
    # 先扣掉它才是真正的行數,再扣掉正在改的這一行,剩下的才是「其他」。
    # (本站測試機的專案資料夾裡六份 spch_cht.txt 位元組完全相同:6,835 bytes、CRLF、
    #  193 行,也就是上面說的 90 個群組 + 103 個音檔,所以這裡會印 192,不是 193。)
    total_lines = len(lines) - 1 if lines and lines[-1] == b'' else len(lines)
    print('  其他 %d 行一個位元組都不會動。' % (total_lines - 1))

    if not apply_it:
        print()
        print('  以上是預覽,還沒有動到任何檔案。')
        print('  確定要改的話,在剛才那一行最後面加上 --apply')
        return

    # 只碰 line_no 那一格。其餘每一行都是從原檔切出來的同一串位元組,原樣接回去。
    new_lines = list(lines)
    # 保留原本的縮排與行尾空白,只換群組名本身
    # (在那一行裡取代第 1 次,不是整份檔案搜尋取代;後者會誤傷長得一樣的別行。)
    new_lines[line_no] = lines[line_no].replace(old_name.encode('latin-1'), new_line, 1)
    # 用原檔那一種換行接回去,不用系統預設的。
    out = nl.join(new_lines)

    # 寫之前先自我檢查:群組數、音檔數要跟原本一樣,而且不可以有重複群組名
    # 三道驗算合起來的意思是:「這次改動只換掉一個名字,沒有多出、少掉或錯位任何東西」。
    # 三道都在**記憶體裡**跑完才動磁碟,所以任何一道沒過,遊戲檔連碰都沒被碰到。
    g0, f0, _ = check_index(lines)
    g1, f1, dup = check_index(new_lines)
    if (g0, f0) != (g1, f1):
        raise DataError('改完之後群組或音檔數變了(%d/%d → %d/%d),中止。' % (g0, f0, g1, f1))
    if dup:
        raise DataError('改完之後出現重複的群組名:%s,中止。' % '、'.join(dup))
    if len(out) != len(raw) + (len(new_line) - len(old_name.encode('latin-1'))):
        raise DataError('改完之後檔案長度不符預期,中止。')

    # 備份只做第一次。第二次以後保留最早那一份,
    # 因為那一份才是「你動手之前」的樣子;拿改過的去覆蓋等於把還原點弄丟。
    backup = idx + BACKUP_SUFFIX
    # 要寫的那兩個名字,動手之前先確認都不是「指到別處的符號連結」。
    _no_symlink(idx, '索引檔')
    _no_symlink(backup, '備份檔')
    # 這裡用 lexists 不用 exists:指向不存在目標的死連結,在 exists 眼裡
    # 是「不存在」,會被當成「還沒備份過」而走進去覆蓋它(2026-09-05 稽核)。
    if not os.path.lexists(backup):
        _atomic_copy(idx, backup)
        print()
        print('  已備份 → %s' % os.path.basename(backup))
    else:
        # 已經有備份就保留最早那一份 —— 但先確認它還能用。等到要還原時才發現
        # 備份是 0 bytes 就太晚了:那時遊戲檔已經改過,而那份備份救不回來。
        # 驗備份的力氣不能全放在還原端,寫入才是不可逆的那一步。
        if not os.path.isfile(backup) or os.path.getsize(backup) == 0:
            raise DataError('%s 已經存在,但它不是一份可用的備份'
                            '(不是檔案,或是 0 bytes)。\n'
                            '  請先把它刪掉或改名,再跑一次(腳本會重新備份一份完整的)。'
                            % os.path.basename(backup))
        print()
        print('  備份已存在,保留最早那一份 → %s' % os.path.basename(backup))
    # 先寫一個暫存檔再原子改名。中途斷掉時遊戲看到的還是原本那個完整的索引檔,
    # 不會是一個寫到一半的。
    # ⚠️ 暫存檔的名字不可以是猜得到的 idx + '.tmp'(2026-09-05 稽核):
    #    有人先在那個名字上擺一個指到資料夾外面的符號連結,
    #    open(那個名字, 'wb') 就會跟過去把外面那個檔截掉。實測舊版中招。
    #    _mkstemp_beside() 用 O_CREAT|O_EXCL 開隨機名字,先擺也擺不中。
    global _REPLACE_STAGE
    fd, tmp = _mkstemp_beside(idx, 'tmp')
    try:
        with os.fdopen(fd, 'wb') as f:
            f.write(out)
            f.flush()
            os.fsync(f.fileno())
        shutil.copymode(idx, tmp)   # 權限照原本的,不要留下 mkstemp 的 0600
        _REPLACE_STAGE = 1      # 先標記再換,理由見 _REPLACE_STAGE 那一段
        # 跟 _do_copy() 同一個寫法:換名與登記之間不留給 Ctrl-C 插隊的縫。
        with _NoInterrupt():
            os.replace(tmp, idx)
            _REPLACE_STAGE = 2
    except BaseException as e:
        # 寫到一半被中斷(磁碟滿、按 Ctrl-C)時,把那個半截的暫存檔清掉,
        # 跟 _atomic_copy() 是同一個道理:不要在遊戲資料夾裡留垃圾。
        # 遊戲檔本身這時候還是原本那一份完整的,因為改名還沒發生。
        _drop(tmp)
        # 改名那一步自己丟 OSError 時(2026-09-26 加),登記停在 1 的話收尾會叫你
        # --restore —— 而備份是最早那一份,改過第二次的人照做會連第一次一起退掉。
        # 所以讀回來跟這一次動手之前的 raw 比,見 _settle_failed_replace()。
        if _REPLACE_STAGE == 1 and isinstance(e, OSError):
            _settle_failed_replace(idx, raw, out)
        raise

    # 複驗:重新讀一次
    raw2, lines2, _ = read_index(idx)
    g2, f2, dup2 = check_index(lines2)
    got = [n for _, n in parse_groups(lines2) if n == new_line.decode('latin-1')]
    print('  已寫入。複驗:群組 %d · 音檔 %d · 重複群組名 %d 個 · 新群組名找得到 %s'
          % (g2, f2, len(dup2), '✅' if got else '❌'))
    if (g2, f2) != (g0, f0) or dup2 or not got:
        # 複驗沒過就是**非零結束碼**收場(DataError → 結束碼 2),
        # 絕不會印一個 ❌ 然後當作沒事回 0。順手把還原指令整句寫出來,
        # 讀者不用回頭翻教學頁就照著貼得回去。
        raise DataError('複驗沒過 —— 寫出去的索引檔跟預期對不上。\n'
                        '  請先還原,再回報這個訊息:\n'
                        '    python3 -I %s "%s" --restore'
                        % (os.path.basename(sys.argv[0]), gamedir))
    print()
    print('  完成。進遊戲,讓 %s 上場打擊,聽聽看應援曲有沒有變。' % target_name)
    print('  ⚠️ 本站只驗到「檔案改對了」。遊戲會不會照這個索引播放,')
    print('     要靠你的耳朵確認 —— 本站沒有辦法自動測這件事。')


def cmd_restore(gamedir):
    """把備份放回去。備份本身留著不刪,所以還原完還可以再還原一次。

    把關分三段:
      1. 這裡先把備份**當成索引檔讀一次**(群組數、音檔數、有沒有重複的群組名、
         結尾是不是完整的一行)。這個檔是純文字,驗得起來就不要退到只看大小。
      2. _restore_from_backup() 再擋一次(0 bytes、通用地板那幾道)。
      3. 蓋回去之後**整份逐位元組**跟備份對一次,對不上就不印成功。
    """
    # 刻意不走 chant_paths():索引檔被刪掉或被別的工具搬走的時候,正是最需要
    # 還原的時候,而 chant_paths() 看到索引檔不在就擋下來、還叫你去檢查路徑 ——
    # 方向是反的。這裡只要求「你給的是遊戲資料夾」,不要求索引檔還在。
    idx = os.path.join(gamedir, 'data', 'audio', 'chants', 'spch_cht.txt')
    backup = idx + BACKUP_SUFFIX
    # 還原是「往正本寫」的動作,所以兩個名字都要先確認不是符號連結。
    # 這裡先擋一次是為了在**讀備份之前**就停:訊息才講得清楚是哪一個檔的問題。
    # (_do_copy() 裡面還會再擋一次 —— 那一道是總開關,不能拿掉。)
    _no_symlink(idx, '要還原的目標檔')
    _no_symlink(backup, '備份檔')
    # 用 lexists:死連結在 exists 眼裡是「不存在」,會被誤報成「找不到備份」。
    if not os.path.lexists(backup):
        if not os.path.isdir(os.path.dirname(idx)):
            raise DataError('找不到 %s\n'
                            '  請確認你給的是遊戲資料夾(裡面看得到 mvp2005.exe 跟 data)'
                            % os.path.dirname(idx))
        raise DataError('找不到備份 %s —— 沒有東西可以還原。' % os.path.basename(backup))

    # 用格式驗備份,不要只靠 _restore_from_backup() 的通用地板。
    # 那一道只擋「不到一半」:本站實測 6,835 bytes 的索引,備份被截成 3,418 bytes
    # (50.007%)照樣印「已還原」、結束碼 0,完整的正本當場被半截檔蓋掉。
    _, bak_lines, _ = read_index(backup)
    bg, bf, bdup = check_index(bak_lines)
    if bdup:
        raise DataError('備份裡有重複的群組名(%s),不敢拿它還原。' % '、'.join(bdup))
    if bg < 1 or bf < 1 or bak_lines[-1] != b'':
        raise DataError('備份看起來被截斷了(群組 %d 個 · 音檔 %d 個 · '
                        '結尾不是完整的一行),不敢拿它還原。\n'
                        '  請改用你自己另外留的那一份備份。' % (bg, bf))
    # 再跟現在那個檔比一次,但**只擋「備份比現在這個檔還少」**。
    # 剛好切在行尾的半截備份會整組整組地少掉,這一道就是在抓它。
    # 反過來(備份比較多)不擋 —— 那正是「正本壞掉、要靠備份救回來」的情況,
    # 擋下去就跟上面那個「索引檔被刪掉反而不給還原」犯同一種錯。
    if os.path.isfile(idx):
        _, live_lines, _ = read_index(idx)
        lg, lf, _ = check_index(live_lines)
        if bg < lg or bf < lf:
            raise DataError('備份只有 %d 組 %d 個音檔,現在那個檔有 %d 組 %d 個 ——\n'
                            '  備份看起來被截斷了,不敢拿它還原。\n'
                            '  請改用你自己另外留的那一份備份。' % (bg, bf, lg, lf))
    _restore_from_backup(backup, idx)
    # 印「已還原」之前先自己看一次:**整份**比完,不是比開頭,也不是兩邊長度
    # 不一樣就在短的那一邊停(用 zip() 兩兩配對就會那樣停,短的比完就結束,
    # 長的那一邊多出來的位元組永遠沒被看過)。
    with open(backup, 'rb') as f:
        want = f.read()
    with open(idx, 'rb') as f:
        got = f.read()
    if want != got:
        raise DataError('還原之後的檔案跟備份對不起來(備份 %d bytes、'
                        '還原後 %d bytes)。\n'
                        '  先不要進遊戲,請確認磁碟空間與這個資料夾的寫入權限。'
                        % (len(want), len(got)))
    print('  已還原 %s ← %s' % (os.path.basename(idx), os.path.basename(backup)))
    print('  備份檔留著沒刪。')


def main():
    """讀參數,分派到 --restore / --give / --list,並把結束碼收成一致的三種。

    沒給任何開關就跑 --list,因為那是唯一絕對不寫檔的動作。
    結束碼:0 正常、2 自己停下來(DataError,或作業系統擋下這個動作)、
    130 你按了 Ctrl-C。備份沒過 _restore_from_backup() 那幾道、
    或是要寫的名字被符號連結佔住時,由 SystemExit 結束,結束碼是 1。
    停下來的理由後面**只有在遊戲檔真的動過的時候**才多一句 _state_note()
    (「已經換過了」加還原指令);還沒動磁碟就停手的那些 —— 也就是你平常
    會看到的那些 —— 輸出一個字都沒有變。
    按 Ctrl-C 的訊息會分兩種寫:遊戲檔還沒換過就說「一個位元組都沒有動到」,
    已經換過就叫你去 --restore —— 那句話得是真的,不能一律安慰。
    換名跟登記之間不留縫(_NoInterrupt),所以這兩句不會講反。
    --selftest 不走這裡(在最底下就先攔下來了):全過回 0、有一道沒過回 1、
    在 python3 -O 底下拒跑回 2。
    """
    EPILOG = (
        "\n例子(照順序做):\n\n"
        "  1. 看遊戲裡有哪些球員專屬應援曲\n"
        "     python3 -I mvp_swap_chant.py \"<遊戲資料夾>\" --list\n\n"
        "  2. 預覽:把 0004 那首指給陳金鋒(不會動到檔案)\n"
        "     python3 -I mvp_swap_chant.py \"<遊戲資料夾>\" --give Chin-Feng 0004\n\n"
        "  3. 確定了才真的改\n"
        "     python3 -I mvp_swap_chant.py \"<遊戲資料夾>\" --give Chin-Feng 0004 --apply\n\n"
        "  還原:\n"
        "     python3 -I mvp_swap_chant.py \"<遊戲資料夾>\" --restore\n"
    )
    ap = argparse.ArgumentParser(
        description='把某位球員的專屬應援曲指給另一位球員',
        formatter_class=argparse.RawDescriptionHelpFormatter, epilog=EPILOG)
    ap.add_argument('gamedir', help='遊戲資料夾(裡面看得到 mvp2005.exe 跟 data)')
    ap.add_argument('--list', action='store_true', help='列出遊戲裡的球員專屬應援曲')
    ap.add_argument('--give', nargs=2, metavar=('球員', '來源編號'), help='把某首應援曲指給某位球員')
    ap.add_argument('--apply', action='store_true', help='真的寫入(沒加就只是預覽)')
    ap.add_argument('--restore', action='store_true', help='從備份還原')
    args = ap.parse_args()

    try:
        if args.restore:
            cmd_restore(args.gamedir)
        elif args.give:
            cmd_give(args.gamedir, args.give[0], args.give[1], args.apply)
        else:
            cmd_list(args.gamedir)
    except DataError as e:
        # 絕大多數的 DataError 是「還沒動磁碟就停手」,那時只印停手的理由 ——
        # 頁面上的範例輸出就是那個樣子,一個字都不動。
        # 但**換名之後**才丟出來的 DataError 不一樣(例如複驗時讀不回來、
        # 讀回來的行數不對):那時遊戲檔已經換過了,只說「停下來了」會讓人
        # 以為什麼都沒發生。跟 Ctrl-C 與 OSError 兩條路走同一個 _state_note(),
        # 三條路就不會有一條說「沒動到」、另一條說「已經換過」。
        #
        # 訊息自己已經把還原指令寫出來的那一種(複驗沒過),就不要再加一次:
        # 同一行指令印兩遍會讓人以為是兩件不同的事。實測過那條路徑會重複,
        # 所以這裡多一個 '--restore' in str(e) 的但書。
        if _REPLACE_STAGE == 0 or '--restore' in str(e):
            print('\n  停下來了:%s\n' % e)
        else:
            print('\n  停下來了:%s\n  %s\n' % (e, _state_note(args.gamedir)))
        return 2
    except KeyboardInterrupt:
        # ⚠️ 不可以一律說「什麼都沒有動到」(2026-09-05 稽核)。
        #    要看 os.replace() 那一刻到底過了沒 —— 那是唯一會換掉遊戲檔的動作,
        #    由 _state_note() 統一講。不管動沒動,結束碼都是 130,
        #    不會因為「反正檔案好好的」就回 0。
        print('\n  已中斷。%s\n' % _state_note(args.gamedir))
        return 130
    except OSError as e:
        # 磁碟滿、檔案被設成唯讀、路徑被別的東西佔住 —— 這些不是「檔案內容不對」,
        # 所以不是 DataError,但也不該讓讀者看到一整串 traceback。
        # 寫檔是「先寫一個隨機名字的暫存檔,全部就緒才原子改名」,
        # 所以改名發生之前出的錯,遊戲檔一個位元組都沒被動到 ——
        # 但改名之後才出的錯(例如複驗時讀不到檔)不算。所以這句話交給
        # _state_note() 照 _REPLACE_STAGE 據實講,不打包票說「一定安全」。
        # 改名那一步自己失敗的,在丟到這裡之前已經讀回來比過了(2026-09-26 加)。
        print('\n  停下來了:作業系統擋下這個動作 —— %s\n'
              '  常見原因:磁碟滿了、檔案被設成唯讀或被鎖定、或那個路徑被別的東西佔住。\n'
              '  %s\n' % (e, _state_note(args.gamedir)))
        return 2
    return 0


# ─────────────────────────────────────────────────────────
#  自我測試(--selftest,2026-09-24 加)
# ─────────────────────────────────────────────────────────

def selftest():
    """--selftest:在系統暫存資料夾自己造一份最小的遊戲資料夾,把 main() 走得到的每一道守門各驗一次。

    ⚠️ 不碰你的遊戲資料夾。造出來的索引檔與名冊都是假的,全部放在
       tempfile.mkdtemp() 開的那一個資料夾裡,跑完整個刪掉。

    這支走得到的每一道守門都有「餌」(故意做它該擋的事,沒擋就紅)與「陰性對照」
    (做正常的事,它不該擋;少了這一半,一個什麼都擋的版本也會全綠)。
    共用的還原函式 _restore_from_backup() 裡有幾道沒有餌:「找不到備份」與
    「備份是 0 bytes」在 cmd_restore() 就先被擋下來了(後者被「備份看起來被截斷了」
    那一道擋);BIGF、語系檔(LOCH)、執行檔(MZ)那三段要備份開頭剛好是那幾種
    檔頭才會走到,這一課的索引檔是純文字,本站沒有替它們下餌。
    符號連結的檢查多半有兩到三層(呼叫端一道、共用函式裡再一道),
    餌驗的是幾層合起來的結果:只拆掉其中一層,別層照樣擋,這裡不會紅。
      一、找不到索引檔、索引檔行數多得離譜、索引檔的名字是一個資料夾 → 停下來
      二、預覽一個位元組都不寫;--apply 只換那一行、其他行逐位元組不變、
          CRLF 照舊、長度剛好差「新舊群組名的長度差」、備份逐位元組等於原檔
      三、來源不在索引裡、目標本來就有專屬應援曲、來源跟目標是同一個編號、
          audioid 超過四位數、名冊裡找不到、名字撞到兩位、讀不到名冊
          → 停下來,索引檔一個位元組都不動(比的是那一道自己丟出來的話)
      四、寫出去之前的三道驗算(群組數與音檔數、重複群組名、總長度):
          換成會報錯的替身,每一道都必須在動磁碟之前停下來
      五、已經有一份不能用的備份(0 bytes、或是一個資料夾)→ 停下來,不在沒有還原點時動手
      六、換檔那一步失敗(把 os.replace 換成一定丟例外的):索引檔換不上去、
          連備份都換不上去 → 索引檔原封不動、不留暫存檔;索引檔還沒碰過的時候,
          收尾那句話照實說「一個位元組都沒有動到」;寫完讀回來複驗沒過 → 回 2 並給還原指令。
          2026-09-26 加:換名自己失敗時收尾要照讀回來的樣子講 —— 跟動手之前一樣
          (第一次改、改過一次之後再改第二次)→ 說沒動到、不叫你 --restore;
          其實已經換上去了 → 說已經換過了;兩份都不像、或讀不回來 → 說分不出來、給還原指令;
          不用替身、索引檔被鎖定(macOS 的 uchg,做不出來的系統跳過)→ 備份照樣做好、
          不留刪不掉的暫存檔、照實說沒動到
      七、Ctrl-C:換檔之前按 → 說沒動到;換檔之後按 → 不可以說沒動到,要給還原指令;
          換名那一段裡收到真的 SIGINT → 那一段跑完才丟出來;接到改指派與還原的
          真流程上,換名做完才收到 → 要說「已經換過了」
      八、還原:備份被截短(少一組)、結尾不是完整的一行、有重複群組名、找不到備份、
          備份不到正本的一半、給的資料夾不對 → 停下來;還原換檔失敗、還原的暫存檔
          讀回來跟備份對不起來 → 正本原封不動(還原換檔失敗時收尾照讀回來的樣子說沒動到,
          索引檔本來就被刪掉的也一樣,2026-09-26 加);換好之後讀回來跟備份不一樣 → 不印成功;
          正常還原逐位元組回去;索引檔被刪掉也還原得回來;索引檔的名字上是具名管道(FIFO)
          → 不卡住、照樣還原,換名又失敗時說分不出來、給還原指令(2026-09-26 加,
          做不出 FIFO 的系統跳過)
      九、符號連結:索引檔、備份檔是指到資料夾外面的連結 → 改指派與還原都停下來,
          外面那個檔不被動到(這台機器做不出符號連結就跳過,而且會印出來說跳過了)
      十、命令列:給錯資料夾(DataError)印一句話回 2、--list 回 0
      十一、最上面那道「-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)
    global _REPLACE_STAGE
    import contextlib
    import io

    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 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 junk(folder):
        """換檔失敗之後留下來的暫存檔(名字是 .<檔名>.<用途>-xxxx)。"""
        return [x for x in os.listdir(folder) if x.startswith('.spch_cht.txt.')]

    def run_main(*argv):
        real_argv = sys.argv
        sys.argv = ['mvp_swap_chant.py'] + list(argv)
        try:
            return quiet(main)
        finally:
            sys.argv = real_argv

    # 索引檔:4 個群組(一個通用的、兩個球員專屬、一個隊呼)、5 個音檔,CRLF 換行。
    # 群組名那幾行照真的檔補空白到 32 bytes。
    index = b''.join(x + b'\r\n' for x in (
        b'tomahawk'.ljust(32), b'tomahawk01.wav 0',
        b'pchants:0004'.ljust(32), b'pchant.0004a.wav 0',
        b'pchants:0010'.ljust(32), b'pchant.0010a.wav 0', b'pchant.0010b.wav 1000',
        b'tchants:01'.ljust(32), b'tchant.01.wav 0'))
    # 名冊:表頭每一格是「欄號 欄位名」;資料列第 0 格是一串識別碼,不是欄位。
    roster = b'\r\n'.join((
        b'0 first_name,1 last_name,2 playerattrib_audioid',
        b'0a1,0 Derek,1 Jeter,2 4',
        b'0a2,0 Chin-Feng,1 Chen,2 1234',
        b'0a3,0 Wang,1 Alpha,2 2001',
        b'0a4,0 Wang,1 Beta,2 2002',
        b'0a5,0 Big,1 Number,2 25028', b''))

    root = tempfile.mkdtemp(prefix='mvp_swap_chant_selftest-')
    try:
        game = os.path.join(root, 'game')
        chants = os.path.join(game, 'data', 'audio', 'chants')
        db = os.path.join(game, 'data', 'database')
        os.makedirs(chants)
        os.makedirs(db)
        idx = os.path.join(chants, 'spch_cht.txt')
        bak = idx + BACKUP_SUFFIX
        roster_p = os.path.join(db, 'attrib.dat')
        put(idx, index)
        put(roster_p, roster)

        def fresh():
            """每一塊測試都從同一個乾淨狀態開始:原本的索引、原本的名冊、沒有備份。"""
            global _REPLACE_STAGE
            _REPLACE_STAGE = 0
            # 資料夾(不是指到資料夾的連結)整個刪;其餘只要名字在就刪 ——
            # 2026-09-26 改:原本只刪「連結或一般檔」,具名管道(FIFO)會被留下來,
            # 下面的 put() 就卡住等一個不會來的讀取端。
            for p in (idx, bak):
                if os.path.isdir(p) and not os.path.islink(p):
                    shutil.rmtree(p)
                elif os.path.lexists(p):
                    os.remove(p)
            put(idx, index)
            if not os.path.isfile(roster_p):
                put(roster_p, roster)

        print('\n一、找不到檔案、檔案大得離譜')
        check(isinstance(quiet(chant_paths, os.path.join(root, '沒有這個資料夾'))[0], DataError),
              '給的資料夾底下沒有索引檔 → 停下來', True)
        check(quiet(chant_paths, game)[0] == idx, '陰性對照:給對了就找得到索引檔')
        big = os.path.join(root, 'huge.txt')
        put(big, b'x\r\n' * (MAX_LINES + 1))
        check(isinstance(quiet(read_index, big)[0], DataError),
              '索引檔有 %d 行(上限 %d)→ 停下來' % (MAX_LINES + 2, MAX_LINES), True)
        r, _ = quiet(read_index, idx)
        check(isinstance(r, tuple) and r[2] == b'\r\n' and r[0] == index,
              '陰性對照:正常的索引檔讀得進來,換行認得是 CRLF')
        r, _ = quiet(read_index, chants)
        check(isinstance(r, DataError) and '讀不到' in str(r),
              '索引檔的名字其實是一個資料夾(讀不進來)→ 收成一句「讀不到」,不噴作業系統的例外', True)

        print('\n二、預覽與真的改')
        fresh()
        r, out = quiet(cmd_give, game, 'Chin-Feng', '0010', False)
        check(r is None and get(idx) == index and not os.path.lexists(bak),
              '陰性對照:沒加 --apply 一個位元組都不寫、不留備份')
        r, out = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
        after = get(idx)
        want = index.replace(b'pchants:0010', b'pchants:1234', 1)
        check(r is None and after == want,
              '陰性對照:--apply 只把那一行換成 pchants:1234,其他位元組全部一樣')
        check(after.count(b'\r\n') == index.count(b'\r\n') and b'\n' not in after.replace(b'\r\n', b''),
              '陰性對照:換行照舊是 CRLF,行數沒變')
        check(os.path.isfile(bak) and get(bak) == index, '陰性對照:備份逐位元組等於動手之前的索引')

        print('\n三、該停下來的指派')
        # 每一種都要停在「它自己那一道」:最後一欄是那一道丟出來的例外裡的話。
        # 只看有沒有停下來不夠 —— 例如目標本來就有應援曲那一種,就算那一道被拆掉,
        # 後面「寫出去之前不可以有重複群組名」那一道也會擋住,只看結果分不出是誰擋的。
        # 也只看例外、不看畫面:「本來就有專屬應援曲」那一句在丟例外之前就先印出來了,
        # 連畫面一起比的話,那一道被拆掉也照樣是綠的。
        for label, who, src, said in (
                ('來源 pchants:0099 不在索引裡', 'Chin-Feng', '0099', '索引裡沒有 pchants:0099'),
                ('目標(Jeter,audioid 4)本來就有專屬應援曲', 'Jeter', '0010', '為了不製造重複的群組名'),
                ('來源跟目標是同一個編號(Jeter 的 pchants:0004 指給 Jeter)', 'Jeter', '0004',
                 '來源跟目標是同一個編號'),
                ('目標的 audioid 25028 超過四位數', 'Number', '0010', '超過四位數'),
                ('名冊裡找不到名字含 Nobody 的球員', 'Nobody', '0010', '找不到名字含'),
                ('名字含 Wang 的有兩位,不替你猜', 'Wang', '0010', '請打長一點')):
            fresh()
            r, out = quiet(cmd_give, game, who, src, True)
            check(isinstance(r, DataError) and said in str(r)
                  and get(idx) == index and not os.path.lexists(bak),
                  label + ' → 停下來,索引檔不動、不留備份', True)
        fresh()
        os.remove(roster_p)
        r, _ = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
        check(isinstance(r, DataError) and '讀不到名冊' in str(r) and get(idx) == index,
              '讀不到名冊卻用名字查人 → 停下來(請你直接給四位數)', True)
        put(roster_p, roster)
        fresh()
        r, _ = quiet(cmd_give, game, '1234', '0010', True)
        check(r is None and get(idx) == want, '陰性對照:直接給四位數 1234 也改得對')

        print('\n四、寫出去之前的自我檢查')
        real_check = check_index
        g = globals()
        # cmd_give 在寫出去之前叫 check_index 兩次:第一次量原本的、第二次量改完的。
        # 替身只在第二次動手腳,模擬「改完之後的內容不對」。
        calls = []

        def dup_after(lines):
            calls.append(1)
            n_g, n_f, dup = real_check(lines)
            return (n_g, n_f, ['pchants:1234']) if len(calls) == 2 else (n_g, n_f, dup)

        def more_after(lines):
            calls.append(1)
            n_g, n_f, dup = real_check(lines)
            return (n_g + 1, n_f, dup) if len(calls) == 2 else (n_g, n_f, dup)

        for label, fake, said in (('改完之後冒出重複的群組名', dup_after, '重複的群組名'),
                                  ('改完之後群組數變了', more_after, '群組或音檔數變了')):
            fresh()
            del calls[:]
            g['check_index'] = fake
            try:
                r, _ = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
            finally:
                g['check_index'] = real_check
            check(isinstance(r, DataError) and said in str(r)
                  and get(idx) == index and not os.path.lexists(bak),
                  label + ' → 在動磁碟之前停下來', True)
        # 第三道(長度):把 read_index 換成「換行報錯成 LF」的替身。
        # 群組數、音檔數都沒變,只有接回去的總長度少了幾個位元組 —— 只有這一道擋得到。
        real_read = read_index

        def wrong_newline(path):
            raw, lines, _nl = real_read(path)
            return raw, lines, b'\n'

        fresh()
        g['read_index'] = wrong_newline
        try:
            r, _ = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
        finally:
            g['read_index'] = real_read
        check(isinstance(r, DataError) and '檔案長度不符預期' in str(r)
              and get(idx) == index and not os.path.lexists(bak),
              '改完之後總長度不等於「原長度 + 新舊名字的長度差」→ 在動磁碟之前停下來', True)

        print('\n五、已經有一份不能用的備份')
        fresh()
        put(bak, b'')
        r, _ = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
        check(isinstance(r, DataError) and get(idx) == index, '備份是 0 bytes → 停下來,索引檔不動', True)
        fresh()
        os.mkdir(bak)
        r, _ = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
        check(isinstance(r, DataError) and get(idx) == index, '備份的名字是一個資料夾 → 停下來', True)
        fresh()
        put(bak, index)
        r, _ = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
        check(r is None and get(idx) == want and get(bak) == index,
              '陰性對照:備份好好的就保留最早那一份,照樣改')

        print('\n六、換檔那一步失敗')
        real_replace = os.replace

        def boom(*a, **k):
            raise OSError(28, '(自我測試的替身)換檔失敗')

        def boom_on_index(src, dst):
            if dst == idx:
                raise OSError(28, '(自我測試的替身)換檔失敗')
            return real_replace(src, dst)

        fresh()
        os.replace = boom_on_index
        try:
            rc, out = run_main(game, '--give', 'Chin-Feng', '0010', '--apply')
        finally:
            os.replace = real_replace
        check(rc == 2 and get(idx) == index and not junk(chants),
              '索引檔換上去那一步失敗 → 回 2、索引檔原封不動、不留暫存檔', True)
        # 2026-09-26 加以下六道:換名那一步自己失敗時,收尾那句話要照讀回來的樣子講。
        check('讀回來確認過' in out and '一個位元組都沒有動到' in out and '--restore' not in out,
              '同一種失敗:讀回來確認索引檔沒變 → 照實說沒動到、重跑就好,不叫你 --restore', True)
        # 改過一次之後再改第二次:備份是最早那一份,這時叫你 --restore 會連第一次一起退掉。
        # 比的必須是「這一次動手之前」那一份,不是備份。
        fresh()
        quiet(cmd_give, game, 'Chin-Feng', '0010', True)     # 第一次:0010 → 1234,成功
        _REPLACE_STAGE = 0
        os.replace = boom_on_index
        try:
            rc, out = run_main(game, '--give', '2001', '0004', '--apply')
        finally:
            os.replace = real_replace
        check(rc == 2 and get(idx) == want and get(bak) == index
              and '讀回來確認過' in out and '--restore' not in out,
              '改過一次之後再改第二次、換名失敗 → 停在第一次改完的樣子,照實說沒動到、不叫你 --restore',
              True)

        def replace_then_fail(src, dst):
            real_replace(src, dst)
            if dst == idx:
                raise OSError(5, '(自我測試的替身)其實換上去了,卻回報失敗')

        def scramble_then_fail(src, dst):
            if dst == idx:
                put(idx, b'selftest: neither before nor after\r\n')
                raise OSError(5, '(自我測試的替身)換檔失敗')
            return real_replace(src, dst)

        for fake, label, ok in (
                (replace_then_fail, '換名回報失敗、讀回來其實已經換上去了 → 照實說已經換過了、給還原指令',
                 lambda o: '已經換過了' in o and '可能已經換過了' not in o),
                (scramble_then_fail, '換名回報失敗、讀回來跟改之前改之後都不像 → 說分不出來、給還原指令',
                 lambda o: '可能已經換過了' in o)):
            fresh()
            os.replace = fake
            try:
                rc, out = run_main(game, '--give', 'Chin-Feng', '0010', '--apply')
            finally:
                os.replace = real_replace
            check(rc == 2 and ok(out) and '一個位元組都沒有動到' not in out and '--restore' in out,
                  label, True)
        _REPLACE_STAGE = 1
        _settle_failed_replace(chants, False, index)     # chants 是資料夾:兩次都讀不回來
        check(_REPLACE_STAGE == 1, '換名失敗之後正本讀不回來 → 維持「分不出來」,不說成沒動到', True)
        # 不用替身、讓作業系統自己擋下換名:把索引檔設成 macOS 的「已鎖定」(uchg)。
        # 備份要照樣做得出來(不可以把鎖定旗標抄到暫存檔上,留下刪不掉的 .part),
        # 索引檔換不上去,收尾要讀回來說沒動到。做不出這個旗標的系統就跳過。
        import stat
        fresh()
        try:
            os.chflags(idx, stat.UF_IMMUTABLE)
            locked = bool(os.stat(idx).st_flags & stat.UF_IMMUTABLE)
        except (AttributeError, OSError):
            locked = False
        if locked:
            try:
                rc, out = run_main(game, '--give', 'Chin-Feng', '0010', '--apply')
                left = junk(chants)
            finally:
                for x in [idx] + [os.path.join(chants, j) for j in junk(chants)]:
                    os.chflags(x, 0)                     # 先解鎖,後面才刪得掉
            check(rc == 2 and get(idx) == index and os.path.isfile(bak) and get(bak) == index
                  and not left and '讀回來確認過' in out,
                  '索引檔被鎖定(作業系統自己擋下換名)→ 備份照樣做好、不留刪不掉的暫存檔、照實說沒動到',
                  True)
            for j in left:                               # 這一道紅了才會有,清掉免得連累後面幾道
                os.remove(os.path.join(chants, j))
        else:
            skipped.append('「已鎖定」的索引檔那 1 道(這台機器或這種磁碟做不出 macOS 的鎖定旗標)')
        fresh()
        os.replace = boom
        try:
            rc, out = run_main(game, '--give', 'Chin-Feng', '0010', '--apply')
        finally:
            os.replace = real_replace
        check(rc == 2 and get(idx) == index and not os.path.lexists(bak) and not junk(chants),
              '連備份都換不上去 → 回 2、索引檔不動、不留半截的備份與暫存檔', True)
        check('一個位元組都沒有動到' in out, '陰性對照:索引檔還沒碰過的時候,收尾照實說沒動到')

        def replace_then_undo(src, dst):
            real_replace(src, dst)
            if dst == idx:
                put(idx, index)             # 換上去之後,內容又變回原本那一份(新群組名不見了)

        fresh()
        os.replace = replace_then_undo
        try:
            rc, out = run_main(game, '--give', 'Chin-Feng', '0010', '--apply')
        finally:
            os.replace = real_replace
        check(rc == 2 and '複驗沒過' in out and '--restore' in out,
              '寫完重新讀回來,新群組名找不到 → 複驗沒過、回 2、給還原指令', True)

        print('\n七、Ctrl-C')

        def stop_before(*a, **k):
            raise KeyboardInterrupt

        fresh()
        real_mk = g['_mkstemp_beside']
        g['_mkstemp_beside'] = stop_before
        try:
            rc, out = run_main(game, '--give', 'Chin-Feng', '0010', '--apply')
        finally:
            g['_mkstemp_beside'] = real_mk
        check(rc == 130 and '一個位元組都沒有動到' in out and get(idx) == index,
              '陰性對照:還沒換檔就按 Ctrl-C → 130、說沒動到,而且真的沒動')

        def replace_then_stop(src, dst):
            real_replace(src, dst)
            if dst == idx:
                raise KeyboardInterrupt

        fresh()
        os.replace = replace_then_stop
        try:
            rc, out = run_main(game, '--give', 'Chin-Feng', '0010', '--apply')
        finally:
            os.replace = real_replace
        check(rc == 130 and '一個位元組都沒有動到' not in out and '--restore' in out
              and get(idx) == want,
              '換檔之後才按 Ctrl-C → 不可以說沒動到,要給還原指令', True)
        import threading
        if hasattr(signal, 'raise_signal') and threading.current_thread() is threading.main_thread():
            ran = []
            got = None
            before = signal.getsignal(signal.SIGINT)
            try:
                with _NoInterrupt():
                    signal.raise_signal(signal.SIGINT)
                    ran.append('這一段跑完了')
            except KeyboardInterrupt:
                got = 'KeyboardInterrupt'
            check(got == 'KeyboardInterrupt' and ran == ['這一段跑完了']
                  and signal.getsignal(signal.SIGINT) is before,
                  '換名那一段裡收到真的 SIGINT → 那一段跑完才丟出來,處理器也裝回去', True)

            # 接到真的流程上:換名一做完就收到真的 SIGINT。登記已經是「換過了」,
            # 收尾要說「已經換過了」,不是「可能已經換過了」,更不是「沒動到」。
            # 改指派與還原兩條路各驗一次(兩條路的登記是分開寫的)。
            def replace_then_sigint(src, dst):
                real_replace(src, dst)
                if dst == idx:
                    signal.raise_signal(signal.SIGINT)

            for label, argv, prep in (
                    ('改指派', ('--give', 'Chin-Feng', '0010', '--apply'), None),
                    ('還原', ('--restore',), 'restore')):
                fresh()
                if prep:
                    put(idx, index.replace(b'pchants:0010', b'pchants:1234', 1))
                    put(bak, index)
                os.replace = replace_then_sigint
                try:
                    rc, out = run_main(game, *argv)
                finally:
                    os.replace = real_replace
                check(rc == 130 and '已經換過了' in out and '可能已經換過了' not in out
                      and '一個位元組都沒有動到' not in out and '--restore' in out,
                      label + '那條路:換名做完才收到真的 SIGINT → 130、照實說已經換過了', True)
        else:
            skipped.append('真的 SIGINT 那三道(這個 Python 沒有 signal.raise_signal,'
                           '或不是在主執行緒裡跑)')

        print('\n八、還原')
        fresh()
        quiet(cmd_give, game, 'Chin-Feng', '0010', True)
        r, _ = quiet(cmd_restore, game)
        check(r is None and get(idx) == index and os.path.isfile(bak),
              '陰性對照:正常還原逐位元組回到原本的樣子,備份留著')
        os.remove(idx)
        r, _ = quiet(cmd_restore, game)
        check(r is None and get(idx) == index, '陰性對照:索引檔被刪掉了也還原得回來')
        cases = (
            ('備份被截在行尾、少了最後一組', index[:index.index(b'tchants:01')], '備份只有'),
            ('備份結尾不是完整的一行', index[:-2], '結尾不是完整的一行'),
            ('備份裡有重複的群組名', index + b'pchants:0004'.ljust(32) + b'\r\n'
             + b'pchant.x.wav 0\r\n', '重複的群組名'))
        for label, data, said in cases:
            fresh()
            live = get(idx)
            put(bak, data)
            r, _ = quiet(cmd_restore, game)
            check(isinstance(r, DataError) and said in str(r) and get(idx) == live,
                  label + ' → 不拿它還原,正本不動', True)
        fresh()
        r, _ = quiet(cmd_restore, game)
        check(isinstance(r, DataError) and '找不到備份' in str(r) and get(idx) == index,
              '找不到備份 → 停下來', True)
        fresh()
        changed = index.replace(b'pchants:0010', b'pchants:1234', 1)
        put(idx, changed)
        put(bak, index)
        os.replace = boom
        try:
            r, _ = quiet(cmd_restore, game)
        finally:
            os.replace = real_replace
        check(isinstance(r, OSError) and get(idx) == changed and not junk(chants),
              '還原換檔失敗 → 正本原封不動、不留暫存檔', True)
        fresh()                                           # 2026-09-26 加:同一種失敗,看收尾那句話
        put(idx, changed)
        put(bak, index)
        os.replace = boom
        try:
            rc, out = run_main(game, '--restore')
        finally:
            os.replace = real_replace
        check(rc == 2 and get(idx) == changed and '讀回來確認過' in out
              and '可能已經換過了' not in out,
              '同一種失敗從命令列跑:讀回來確認正本還是還原之前那一份 → 照實說、重跑同一行就好', True)
        fresh()
        os.remove(idx)                                    # 索引檔被刪掉了,正是最需要還原的時候
        put(bak, index)
        os.replace = boom
        try:
            rc, out = run_main(game, '--restore')
        finally:
            os.replace = real_replace
        check(rc == 2 and not os.path.lexists(idx) and '讀回來確認過' in out,
              '索引檔被刪掉時還原、換名失敗 → 讀回來還是不存在,一樣照實說沒動到', True)

        # 2026-09-26 同一天第二次加:索引檔的名字上是一個具名管道(FIFO)。
        # 換名失敗時拿來比的 _peek() 第一版會去 open 它,一直等一個不會來的寫入端 ——
        # --restore 就卡在那裡(本站實測),而更早的版本是直接換掉、還原成功。
        # 在另一條執行緒裡跑,5 秒沒結束就算紅,再從這邊打開寫入端把它放出來,
        # 不讓整個自我測試跟著卡住。做不出 FIFO 的系統(例如 Windows)就跳過。
        import threading

        def fifo_restore(fake=None):
            """索引檔換成 FIFO、備份照放,跑一次 --restore。回傳 (卡住了沒, 結束碼, 畫面)。"""
            fresh()
            os.remove(idx)
            os.mkfifo(idx)
            put(bak, index)
            box = {}
            screen = sys.stdout

            def go():
                box['r'] = run_main(game, '--restore')

            if fake:
                os.replace = fake
            try:
                t = threading.Thread(target=go)
                t.daemon = True
                t.start()
                t.join(5)
                hung = t.is_alive()
                for _ in range(3):              # 卡住了才會進來:打開寫入端再關掉,放它出來
                    if not t.is_alive():
                        break
                    try:
                        os.close(os.open(idx, os.O_WRONLY | os.O_NONBLOCK))
                    except OSError:
                        pass
                    t.join(2)
            finally:
                os.replace = real_replace
                sys.stdout = screen             # 萬一它還卡著,別讓後面的輸出被收進它的緩衝區
            rc, out = box.get('r', (None, ''))
            return hung, rc, out

        can_fifo = hasattr(os, 'mkfifo')
        if can_fifo:
            try:
                os.mkfifo(os.path.join(root, 'probe-fifo'))
                os.remove(os.path.join(root, 'probe-fifo'))
            except OSError:
                can_fifo = False
        if can_fifo:
            hung, rc, out = fifo_restore()
            check(not hung and rc == 0 and os.path.isfile(idx) and not os.path.islink(idx)
                  and get(idx) == index and '已還原' in out,
                  '索引檔的名字上是具名管道(FIFO)→ --restore 不卡住、照樣還原成一般的檔', True)
            hung, rc, out = fifo_restore(boom)
            check(not hung and rc == 2
                  and os.path.lexists(idx) and stat.S_ISFIFO(os.lstat(idx).st_mode)
                  and '可能已經換過了' in out and '讀回來確認過' not in out
                  and '--restore' in out and not junk(chants),
                  '同樣是 FIFO、換名又失敗 → 不卡住,讀不回來就說分不出來、給還原指令', True)
            fresh()
        else:
            skipped.append('具名管道(FIFO)那 2 道(這台機器做不出 FIFO)')

        real_fsync = os.fsync

        def fsync_then_scribble(fd):
            real_fsync(fd)
            os.lseek(fd, 0, os.SEEK_SET)    # 暫存檔已經寫完、落地之後,才把第一個位元組改掉
            os.write(fd, b'X')

        fresh()
        put(idx, changed)
        put(bak, index)
        os.fsync = fsync_then_scribble      # --restore 這條路上只有 _do_copy 會呼叫 fsync
        try:
            r, _ = quiet(cmd_restore, game)
        finally:
            os.fsync = real_fsync
        check(isinstance(r, SystemExit) and '寫出來的內容跟備份對不起來' in str(r)
              and get(idx) == changed and not junk(chants),
              '還原的暫存檔讀回來跟備份對不起來 → 不換上去、正本原封不動、不留暫存檔', True)

        def replace_then_grow(src, dst):
            real_replace(src, dst)
            if dst == idx:
                with open(idx, 'ab') as f:
                    f.write(b'\r\n')

        fresh()
        put(idx, changed)
        put(bak, index)
        os.replace = replace_then_grow
        try:
            r, _ = quiet(cmd_restore, game)
        finally:
            os.replace = real_replace
        check(isinstance(r, DataError) and '還原之後的檔案跟備份對不起來' in str(r),
              '還原換好之後整份讀回來跟備份不一樣 → 停下來,不印「已還原」', True)

        # 通用地板:正本的群組行被補得很長,備份的組數、音檔數都一樣,
        # 大小卻不到正本的一半 —— 前面那幾道格式檢查都會放行,只剩這一道擋得到。
        fresh()
        padded = b''.join((x.ljust(200) if b'.wav' not in x and x else x) + b'\r\n'
                          for x in index.split(b'\r\n')[:-1])
        put(idx, padded)
        put(bak, index)
        r, _ = quiet(cmd_restore, game)
        check(isinstance(r, SystemExit) and '不到一半' in str(r) and get(idx) == padded,
              '備份不到正本的一半(組數、音檔數卻都一樣)→ 不拿它還原,正本不動', True)
        r, _ = quiet(cmd_restore, os.path.join(root, '沒有這個資料夾'))
        check(isinstance(r, DataError) and '請確認你給的是遊戲資料夾' in str(r),
              '還原時給的資料夾底下連 data/audio/chants 都沒有 → 說路徑可能給錯了,不只說找不到備份', True)

        print('\n九、符號連結')
        outside = os.path.join(root, 'outside.txt')
        can_link = True
        try:
            os.symlink(outside, os.path.join(root, 'probe'))
            os.remove(os.path.join(root, 'probe'))
        except (OSError, NotImplementedError, AttributeError):
            can_link = False
        if can_link:
            fresh()
            put(outside, index)
            os.remove(idx)
            os.symlink(outside, idx)
            r, _ = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
            check(isinstance(r, SystemExit) and get(outside) == index and os.path.islink(idx)
                  and not os.path.lexists(bak),
                  '索引檔是指到資料夾外面的連結 → 停下來,外面那個檔不動、不留備份', True)
            put(bak, index.replace(b'pchants:0010', b'pchants:1234', 1))
            r, _ = quiet(cmd_restore, game)
            check(isinstance(r, SystemExit) and get(outside) == index and os.path.islink(idx),
                  '還原時索引檔是連結 → 停下來,不把備份寫到連結指的地方', True)
            os.remove(bak)
            os.remove(idx)
            fresh()
            put(outside, b'OUTSIDE\r\n')
            os.symlink(outside, bak)
            r, _ = quiet(cmd_give, game, 'Chin-Feng', '0010', True)
            check(isinstance(r, SystemExit) and get(idx) == index and get(outside) == b'OUTSIDE\r\n',
                  '備份的名字是指到外面的連結 → 停下來,索引檔與外面那個檔都不動', True)
            r, _ = quiet(cmd_restore, game)
            check(isinstance(r, SystemExit) and get(idx) == index,
                  '還原時備份是連結 → 停下來,正本不動', True)
            os.remove(bak)
        else:
            skipped.append('符號連結那 4 道(這台機器做不出符號連結)')

        print('\n十、命令列')
        fresh()
        rc, out = run_main(game, '--list')
        check(rc == 0 and '0004  Derek Jeter' in out,
              '陰性對照:--list 回 0,列得出 0004 是 Jeter 的')
        rc, out = run_main(os.path.join(root, '沒有這個資料夾'), '--list')
        check(rc == 2 and '停下來了' in out and 'Traceback' not in out,
              '給錯資料夾 → 印一句話、回 2、不噴 traceback', True)

        print('\n十一、-O 守門')
        check(_opt[0], '假裝開了 -O(把 _optimize_level 換成回傳 1),自我測試拒跑、結束碼 2', True)
        check(_opt[1], '陰性對照:換回原本那一支之後,沒開 -O 的時候守門放行')
    finally:
        _REPLACE_STAGE = 0
        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.
# ─────────────────────────────────────────────────────────