教學 › 拆開一個語音檔

拆開一個語音檔

本站原本把「換播報語音」整條路標成做不到,理由是「音訊編碼沒解開」。 班主任一句話把這個判斷推翻了:「播報語音是一定可以改的,我看外國模組十年前就做出來了。」 他是對的 —— 而且錯的不只是結論,是「未解」被貼在整條路上,而不是真正卡住的那一格。 這一頁是重新去量的結果:外殼解開了,聲道怎麼擺解開了,切法解開了, 寫這一課的時候最後一格還沒—— 那一格 2026-08-29 也解開了,見換掉遊戲的聲音。 四件事分開講。

難度

★★☆ 讀檔,不改檔

時間

約 10 分鐘

會改到遊戲嗎

不會,這支腳本只讀不寫

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

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

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

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

  1. 把腳本存到你的電腦:mvp_read_voice.py(1,165 行,零相依,只讀不寫)。下載後先把它搬進專用資料夾 MVP_Tools(第 0 課操作六)。再打開檔案總管,進到 MVP_Tools,點一下最上面的網址列,把字刪掉改打 cmd 按 Enter,後面的指令都在開出來的這個視窗打(Mac 開終端機先打 cd ~/MVP_Tools)。
  2. 看看你的遊戲裡有哪些語音封裝檔。把引號裡換成你的遊戲資料夾,要指到下一層就是 data 的那一層。

    Windows:

    python -I mvp_read_voice.py "你的遊戲資料夾" --list

    Mac / Linux:

    python3 -I mvp_read_voice.py "你的遊戲資料夾" --list
  3. 拆開單一一個語音檔,看它的切法。剛安裝好的原版沒有散裝的 .dat, 直接指這個檔就行(播報語音全在裡面)。

    Windows:

    python -I mvp_read_voice.py "你的遊戲資料夾\data\audio\cd\spch_pbp\pbpdat.big" --blocks

    Mac / Linux:

    python3 -I mvp_read_voice.py "你的遊戲資料夾/data/audio/cd/spch_pbp/pbpdat.big" --blocks
  4. 畫面印出「切法正確」就代表掃相位掃到了;在編碼 3 的 GSTR 檔上印出 「⚠️ 切法可能不對」,那是值得回報的東西。原版全部是編碼 2, 腳本會說「本站只解開了 GSTR 容器的編碼 3 的擺法」然後停手 —— 那是正常的。

這支腳本不會產生 WAV,也不會寫入任何檔案。想知道為什麼,往下讀;只想複習,跳到📌 重點整理。

先說清楚「未解」該貼在哪

要換一段播報語音,你需要的是 產生一個遊戲吃得下去的檔。 你不需要「把 EA 錄的那段解碼出來」。這是兩件事,而本站原本把它們混成一件。

證據就在檔案裡:剛安裝好的原版, 語音檔頭的編碼欄位 6,995 個檔全部是同一個值; 而社群做的語音包裡,絕大多數是另一個值。 兩種遊戲都吃 —— 那些包當年是真的裝來玩的。

所以真正的那一格是

不是「EA 的編碼是什麼」,而是「怎麼做出一個遊戲接受的檔」。 社群二十年前就走通了第二條路。這一頁把外殼量清楚,是往那裡走的第一步。

外殼:一串首尾相接的區塊

語音檔不是「檔頭 + 資料」,是一串接龍:每一塊自己說自己多長, 讀完就跳到下一塊。

SCHl檔頭。裡面是一串標籤(編碼方式、聲道、取樣數…)
SCCl計數
SCDl聲音資料。可以有很多塊,本頁下半段都在講它
SCEl結束

每塊開頭 4 個位元組是標記、接著 4 個位元組是長度。 檔頭裡的標籤是「標籤 + 長度 + 值」,但有三個標籤沒有長度也沒有值 —— 那三個是本站踩出來的,不照顧它們就會整串解錯位。

怎麼確定標籤真的讀對了

用一條很硬的檢查:標籤解析消耗掉的長度,必須對得上檔頭宣告的長度。 剛安裝好的原版 6,995 個語音檔,6,995 個對得上。

⚠️ 2026-09-05 訂正:這裡原本寫的是「差一個位元組都算失敗」, 那比本站實際的量法嚴格。腳本判定的是差 0 或差 1 都算過 (tag_end in (hdr_len, hdr_len - 1),程式碼裡就留著這句註解)。 重跑那 6,995 個檔的分佈是:2,556 個分毫不差、4,439 個剛好差 1 個位元組, 沒有一個差更多。結論不變(沒有一個檔是解錯位的),但證據強度要照實寫。

⭐ 聲音資料怎麼擺(本站走過最長的一段冤枉路)

本站一開始按常識假設:立體聲嘛,左右交錯放。錯了。

發現的方式很土:把每一段的開頭印出來看。 結果每一段都長這樣 —— 固定 6 個 0,然後一個值:

00 00 00 00 00 00 14 a4 ee 00 00 00 ...
00 00 00 00 00 00 01 1e 34 3d bc 15 ...
00 00 00 00 00 00 02 0e 34 cb f3 55 ...

那個值不是聲音。把它跟「剩下還有多少資料」比一下就懂了:

那個值十進位剩下的資料關係
14 a45,28410,568正好一半
01 1e286572正好一半
02 0e5261,052正好一半

它是右聲道的起點。所以每一塊聲音資料的擺法是:

[4 bytes] 這一段的取樣數
[4 bytes] 左聲道起點(永遠 0)
[4 bytes] 右聲道起點
[......]  左聲道一整段
[......]  右聲道一整段

兩個聲道各自連續,不是交錯的。 本站原本按交錯解,等於把兩個人的話剪碎之後交叉黏起來 —— 難怪出來是雜訊。

驗證

「右聲道起點 = 剩下資料的一半」這條,在社群語音包的 1,945 段裡 1,945 段成立,零例外。

⚠️ 2026-09-05 補母體:「1,945 段」當初是哪一批檔, 本站重跑時已經指認不出來(頁尾說「每一個數字都是實際跑出來的」,那就要說得出母體)。 用同一條規則重量一次說得出母體的那批:本站這台機器 data/audio 底下 321 個雙聲道檔、共 2,382 段,2,382 段全部成立,零例外。 規則本身站得住,缺的是母體標示。

⭐ 一個聲道裡面:15 個位元組一組

知道聲道各自連續之後,下一個問題是「一個聲道裡面怎麼切」。 這裡本站用了一個不必先解碼就能驗的辦法,值得學起來。

假設每 15 個位元組一組、第一個位元組是表頭,而表頭的高半位元組是「四組係數選一組」 —— 那它就只能是 0、1、2、3。於是:從第 0 個位元組開始切、從第 1 個開始切、 從第 2 個開始切…一路試到第 14 個,看哪一種切法讓「高半位元組 ≤ 3」的比例最高。

從第幾個位元組開始切表頭高半位元組 ≤ 3 的比例
051.4%
150.6%
250.3%
397.7%
444.0%
5 – 1443.0% – 52.1%

⚠️ 這張表是「一個檔的一段」的數字,不是全站統計 (2026-09-05 補上母體,同時訂正三格對不上的數字:第 0 格原本寫 44.0%、 第 2 格原本寫 50.6%、第 4 格原本寫 47.6%,5–14 的範圍原本寫 46.7%–52.7%。 為什麼會填成那樣,本站沒有查出來,只把重跑的數字換上去)。量的是社群語音包裡 5826c.dat 的第 1 段、聲道 0 —— 也就是下面「👉 自己跑一次」示範輸出的那一段。 換一段就要重掃:同一個檔的第 2 段贏家是第 0 格(100.0%), 把本站這台機器 data/audio 底下 2,382 段編碼 3 立體聲的左聲道全部彙總起來重算, 贏家也是第 0 格(92.5%)。所以「切在第 3 個位元組」是這一段的答案,不是通則。

切錯位置時大約 50%,那正是亂猜的水準(從整體分佈算出來的期望值是 50.9%)。 切對位置時 97.7%。差距這麼大,切法就不可能是猜的。

左右兩個聲道分開掃都給出 97.7%,但那不算互相印證。 2026-09-05 訂正:本頁原本寫「那是互相印證,不是同一個數字算兩次」—— 這正好就是同一個數字算兩次。本站量到這台機器上編碼 3 的 2,382 段立體聲裡, 有 2,339 段(98.2%)左右聲道逐位元組完全相同(321 個立體聲檔裡有 310 個每一段都這樣), 上面那個範例檔的第 1 段也是其中之一。 真正獨立的第二條證據是下一節的統計不對稱。

順帶量到的:不是每一段都從第 0 個位元組開始

同一個檔的第 1 段前面有 3 個位元組的前置,第 2 段沒有。 所以掃相位不是「量一次記起來」,是每一段都要重新掃。 本站的腳本就是這樣做的。

那些半位元組確實是 4-bit 差分編碼

切法對了,還要確認「裡面裝的真的是聲音」。 把 442 萬個位元組的半位元組統計出來:

半位元組的值012…78…EF
出現比例15.4%12.6%9.8%↘1.0%0.4%↗11.1%13.6%

把 8–F 讀成負數(−8 到 −1)之後,這個分佈是對稱的、以 0 為中心、往兩邊遞減 —— 也就是「這一個取樣跟上一個差多少」的長相,小差值多、大差值少。 位元組的熵是 6.5 bits(完全隨機或壓縮過的資料會是 8)。

還有一個很漂亮的旁證

高半位元組在「2」的地方多了 5.2%(15.0%,而低半位元組同一格只有 9.8%)。 低半位元組沒有這個隆起。

那多出來的 5.2%,就是每 15 個位元組一個的表頭(1 ÷ 15 = 6.7%)—— 它們的係數編號絕大多數是 2。換句話說: 光看統計的不對稱,就能反推出「每 15 個位元組有一個特別的位元組」, 而這跟掃相位是兩條互相獨立的證據。

取樣率 48,000 Hz(推論,但三條證據互相對得上)

多數語音檔沒有標取樣率。本站的判斷靠三件事:

  1. 舊資料裡那批當年做語音用的來源錄音,那個資料夾裡 670 個檔,669 個是 48,000 Hz (見播報員為什麼不唸新球員的名字; 剩下那一個是 24 kHz 的 MP3,不是 PCM)。 腳本開頭寫的是「681 個來源 WAV 裡 680 個是 48,000 Hz」—— 那是把舊資料整個底下的 .wav 都算進去的口徑, 兩個數字都是量出來的,差別在分母。
  2. 同一批資料裡 2008 年那篇教學,給的指令正好是「重混成立體聲、重取樣到 48000」。
  3. 那些來源錄音裡,188 個的取樣數跟語音檔精確相同, 名字也對得上。取樣數完全一樣 = 沒有重新取樣。

第 3 條做了隨機對照

「188 個吻合」聽起來多,但要先問隨機會吻合幾個。 本站把語音檔的取樣數整體平移一個隨機量(分佈形狀不變、配對關係破壞)跑 200 次: 中位數吻合 4 個、第 95 百分位 22 個、最大 60 個。

實際的 188 高於 200 次對照的最大值。配對關係是真的,不是巧合。

⚠️ 這一步仍是推論:直接量到的是「長度一樣」,不是「內容一樣」。 而且這只涵蓋那批社群檔 —— EA 原版沒有標取樣率,本站測不出來。

⚠️ 2026-09-05:這一格本站還沒定案

重跑時抓到一個矛盾:這批社群檔自己標著的取樣率不是 48,000 Hz。 本站這台機器 data/audio 底下編碼 3 的 8,767 個項目, 檔頭 0x84 那一欄的分佈是 22,050 Hz 有 7,841 個、44,100 Hz 241 個、24,000 Hz 148 個、 沒有標的 537 個 —— 標 48,000 Hz 的一個都沒有。

「語音檔也是 48,000 Hz」跟「檔案自己標的取樣率」最多只能對一個。 本站沒有量出哪一個對(試過拿解出來的取樣跟來源錄音比包絡: 真配對 6 個平均 0.27、隨機對照平均 0.14 但最大值 0.54,那個指標分不開兩者), 所以兩邊都照原樣寫給你看,不替你選一邊。 腳本現在也只替 GSTR 容器的編碼 3 印「若是 48000 Hz 則為 X 秒」, 而且那一行後面接著「尚未定案」。

上面那個標題「三條證據互相對得上」是寫這一課時的判斷。 三條證據彼此仍然對得上,對不上的是檔案自己標的那一欄 —— 這一節留著不刪,但它現在是一個開放問題,不是結案。

⭐ 155 個台灣球員的兩位播報員,是同一段錄音

本站已經解開過 a / c / d 三個後綴的意思: a 是球評唸姓、d 是主播唸姓、c 是主播唸全名 (那一頁有完整推導)。 那麼 a 跟 d 應該是兩個人各唸一次,內容不同。

把台灣球員語音包的檔案逐位元組比對:

組數內容完全相同
當年的來源錄音(英文姓氏)1850 組(0.0%)
實際出貨的台灣球員語音包159155 組(97.5%)

來源錄音那 185 組裡,184 組連檔案長度都不一樣(anderson_a 99,004 位元組、 anderson_d 127,704)—— 真的是兩段不同的錄音。 但輪到台灣球員,兩個槽位放的是同一段。

2026-09-05 訂正:這一段原本寫「那 185 組,連檔案長度都不一樣」, 那是一句沒有例外的全稱句。重跑逐檔比大小,有 1 組長度相同 ——Cruz 的兩個檔各 115,956 位元組,內容仍然不同。 「185 組 0 組內容相同」這個結論沒變,是措辭要收。

為什麼會這樣(推論)

英文姓氏是播報員本人錄過的,兩位各有一份現成的。 但「田」「陽」「林威助」這些,英文播報員從來沒唸過 —— 做模組的人得從英文播報裡剪出中文單字,一個字一個字拼出來。 拼一次就很費工,拼兩次沒有意義,於是同一段放進兩個槽位。

這是推論,不是量到的。量到的是「155/159 相同」與「來源 185/185 不同」這兩個數字。

那 4 個例外也有意思:中村紀洋、陽建福、高政華、林威助。 其中高政華與陽建福那兩組長度一模一樣、內容卻不同 —— 等長的兩段不同錄音 (陽建福的 4165a 與 4165d 各 4,096 位元組,其中 3,623 個位元組不一樣)。

回頭看,2008 年那篇教學裡有一句「至於兩個誰是誰念的已記不清」。 現在知道為什麼記不清了:對台灣球員來說,那兩個本來就一樣。

本站當時沒解開的:就這一格(後來解開了)

15 個位元組怎麼變回 28 個取樣的還原公式。

試過的組合:位移基準 0–24、半位元組高低順序兩種、三張係數表、 兩種濾波器寫法、前置位元組長度 0–16 —— 一千種以上。 拿有標準答案的配對去對,最好的相關係數只有 0.16,等於沒有。

切法對了、統計對了、長度迴歸對到 0.16%(每個取樣 1.0697 位元組, 理論值 1.0714),就差最後這一步。

⚠️ 2026-08-29:這一格解開了

這一節原本的標題是「❌ 本站沒解開的:就這一格」。上面那段冤枉路留著不刪 —— 但那一格後來真的解開了,公式、量測與可以寫回去的腳本都在 換掉遊戲的聲音。

解開它的不是更聰明的組合,是一份標準答案。 免費的 ffmpeg 解得開遊戲的選單音樂,那就是一份可以逐取樣比對的對照組。 有了它把參數空間掃一遍,1,024 種組合裡只有一組給出 100.00%, 第二名 96.90%,第三名掉到 18.91% —— 不是勉強領先,是斷崖。

那一千次為什麼怎麼掃都不會中,還有第二個原因:這個遊戲有兩種編碼器, 而那些嘗試是拿編碼器 1 的參數去套編碼器 2 的資料。 這一頁下面「一個差點推翻正確結論的假紅燈」講的是同一件事的另一面 —— 沒有標準答案的時候,一千次嘗試不會收斂,只會累積。

但這一格不擋你換語音

社群的工具做得出遊戲吃得下去的檔,那條路二十年前就通了。 本站不重貼那些工具本體,站上也沒有記下那些語音工具的名字—— 社群工具那一頁收的是能力值、臉、介面、球衣那幾類, 沒有語音那一列。想自己動手做出遊戲吃得下去的檔, 用換掉遊戲的聲音那一課的腳本。

⭐ 這一課最值錢的部分:一個差點推翻正確結論的假紅燈

過程中本站做了一件事:想確認「那批來源錄音真的是這些語音檔的原料嗎」。 做法是量每一組的殘差大小,跟原始錄音的音量起伏比對 —— 想法是「聲音大的地方,殘差應該也大」。

結果:相關 −0.009,比隨便抓別人的檔(+0.10)還低。 本站當下差點寫出「取樣數吻合是巧合,那批錄音跟語音檔無關」。

那個測法本身是錯的

差分編碼本來就會自動正規化:每一組都有自己的縮放倍率, 所以大聲小聲的地方,殘差都用滿整個範圍。 殘差不跟音量相關,才是正常的。

換句話說,本站拿了一個保證測不到訊號的指標, 去對一個真實存在的關係下否定結論。差一點就發表了。

抓回來的方式是隨機對照:先不管波形,直接問「188 個吻合,隨機會有幾個」。 答案是最多 60 個。關係是真的 —— 測不到是測法的問題,不是關係的問題。

可以帶走的兩句話

一、「配對是真的」跟「我解得出來」是兩個獨立的問題。 本站一路上用「解得出波形」當成驗證配對的方法,把兩件事綁死了,繞了很久。

二、否定結論跟肯定結論一樣要驗。 而且要驗的第一件事不是資料,是「我這個指標,在答案為真的情況下,會不會亮」。

👉 自己跑一次

步驟 1:把腳本存到你的電腦。

mvp_read_voice.py(1,165 行,零相依,只讀不寫)

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

步驟 2:看看你的遊戲裡有哪些語音封裝檔。把引號裡換成你的遊戲資料夾。

Windows:

python -I mvp_read_voice.py "你的遊戲資料夾" --list

Mac / Linux:

python3 -I mvp_read_voice.py "你的遊戲資料夾" --list
你會看到
  data/audio/cd/spch_pa/pnamedat.big        2106 個語音 · 編碼 2×2106
  data/audio/cd/spch_pa/tnamedat.big          91 個語音 · 編碼 2×91
  data/audio/cd/spch_pbp/pnamedat.big       4580 個語音 · 編碼 2×4580
  data/audio/cd/spch_pbp/stdnmdat.big         94 個語音 · 編碼 2×94
  data/audio/cd/spch_pbp/tnamedat.big        124 個語音 · 編碼 2×124

「編碼 2」= EA 自己錄的。如果你裝過語音模組,會看到「編碼 3」。

上面這五行是本站在剛安裝好的原版上跑出來的 (2026-09-05 訂正:這裡原本示範的兩行是 spch_pbp/pnamehdr.big 與 spch_pa/pahdr.big, 那兩行永遠不會出現 —— 前者裝的是 16 個位元組的檔頭不是聲音、 後者根本不是 BIGF 封裝檔)。 你裝過的模組不同,檔名與數字都會不一樣。

如果沒看到

步驟 3:拆開單一一個語音檔,看它的切法。 剛安裝好的原版沒有散裝的 .dat(語音全包在 .big 裡), 而這支腳本沒有取出功能,所以直接指上面說的那種 「本身就是一長串語音」的檔最快:

Windows:

python -I mvp_read_voice.py "你的遊戲資料夾\data\audio\cd\spch_pbp\pbpdat.big" --blocks

Mac / Linux:

python3 -I mvp_read_voice.py "你的遊戲資料夾/data/audio/cd/spch_pbp/pbpdat.big" --blocks

2026-09-05 訂正:這一步原本寫的是 python3 -I mvp_read_voice.py "某個語音檔.dat" --blocks, 而讀者手上不會有任何 .dat(本站數過原版 data/audio 底下 82 個非 .big 的檔,.dat 是 0 個)。 散裝的 .dat 多半來自社群語音包,有的人才有。 要把某一段撈成 WAV 是換掉遊戲的聲音那一課的 mvp_audio.py --export,不是這一支。

你會看到
  第 1 段  宣告 9604 取樣 · 10576 bytes · 每聲道 5284 bytes  ✅
     聲道 0  起點 8      長度 5284   前置 3 bytes · 352 組 × 15 bytes
             表頭高半位元組 ≤3 占 97.7%   切法正確

⚠️ 上面這段輸出來自社群語音包裡的一個雙聲道檔 (編碼 3、GSTR 容器)。剛安裝好的原版全部是編碼 2, 所以你在 pbpdat.big 上會看到外殼那幾行, 接著是一句「這個檔是編碼 2,本站只解開了 GSTR 容器的編碼 3 的擺法」然後停手 —— 那不是壞掉,是腳本刻意不硬套一套沒驗過的切法。

「切法正確」代表掃相位掃到了。如果印出「⚠️ 切法可能不對」, 那個檔的擺法跟本站量到的不一樣 —— 在編碼 3 的 GSTR 檔上,那是值得回報的東西 (本站量到這一類只有約 2.2% 會印這一行)。 別的編碼、以及 PT 容器的編碼 3,腳本根本不會走到這一步。

這支腳本不會產生 WAV(但不是因為做不到)

這一課是唯讀的解剖課,只負責讓你看懂檔案長什麼樣子。 要真的把語音變成 WAV、或把你自己錄的聲音塞回去, 去換掉遊戲的聲音那一課 —— 那支工具兩個方向都做得到。

⚠️ 2026-08-29 訂正:這張卡片原本寫的是 「因為最後那一格沒解開,本站不提供會出雜訊的功能」。 那句話在寫下來的當天稍後就過期了 —— 15 個位元組還原成 28 個取樣的公式已經解開, 對照 ffmpeg 驗過 21,678,328 個取樣 0 個不同。 解開它的不是更聰明的組合,是找到一份標準答案。經過寫在那一課裡。

📌 重點整理

接下來

這支腳本在做什麼

一句話:把語音檔的外殼拆開,把量得出來的東西印給你看,全程唯讀。 流程分三層,一層比一層裡面。最外面是 BIGF 封裝檔, --list 只走到這一層,列出每個封裝檔裡有幾段語音、分別用哪種編碼。 中間是單一語音檔的區塊鏈(SCHl 檔頭、 SCCl 計數、SCDl 聲音資料、SCEl 結束), 不加旗標就是走這一層。最裡面是聲音資料自己怎麼擺, 只有加了 --blocks 才會走進去。

三層各配一個會失敗的檢查,不是「看起來對」就算數: 檔頭那串標籤走完的位置要等於檔頭自己宣告的長度、 每個聲道扣掉起點表之後要一樣長、 切成 15 個位元組一組之後表頭的高半位元組要幾乎都 ≤ 3。 會失敗的檢查通過了才有意義,所以畫面上那幾個 ✅ 是量出來的,不是印出來好看的。

哪一段做什麼為什麼要有它
main() 收指令列參數,決定要跑 --list 還是看單一檔案;看單一檔案時,把「讀不懂」「找不到檔案」「給了資料夾卻沒加 --list」 「沒有權限讀」這四種狀況各印成一句人話。 這支只有三種用法,判斷集中在一個地方就講得完。 會拿它來看自己遊戲的人不需要一整片 traceback, 所以預期得到的這四種失敗都收成一句話,回傳 2。 ⚠️ 2026-09-26 補:「給了資料夾」原本只接得住 Mac / Linux 的那一種錯誤。 Windows 上打開一個資料夾,Python 丟的是「沒有權限」那一種, 原本會變成一整片 traceback(這一句是推論,本站還沒在 Windows 上實測)。 現在開檔之前就先問「這是不是資料夾」,兩邊印的是同一句話; 真的沒有權限讀的檔,印的是另一句,不會被說成資料夾。 --list 不走這四句:這四句講的都是「你給的那一個檔」, 資料夾裡哪一個 .big 讀不到,交給下面的 cmd_list() 點名。
cmd_list() 掃資料夾底下的 .big,逐項認出哪些是語音, 統計每種編碼各有幾個。 找不到 data/audio 就直接掃你給的那一層, 所以給錯一層、或給一個已經拆開的散裝資料夾,都不用重打指令。 個別項目讀不懂就跳過:一份概觀不該被一個壞項目卡住。 整個 .big 讀不到(例如這個帳號沒有讀取權限)也一樣: 點名是哪一個、跳過,最後再說一次這份清單不完整,結束碼照樣是 0 (2026-09-26 補:在這之前,一個讀不到的 .big 會讓整份清單停在半路)。
big_entries() 讀 BIGF 封裝檔的目錄,回「名稱、位移、大小」。 檔頭固定 16 個位元組,這支腳本只用項目數那一欄, 而那一欄是大端序。 語音是包在封裝檔裡的,先有目錄才知道每一段從哪裡開始。 ⚠️ 這一層的大端序跟裡面區塊長度的小端序方向相反, 弄反不會當掉,只會安靜地讀出一堆天文數字。 ⚠️ 2026-09-05 訂正:本頁原本寫「裡面的數字全是大端序」, 那句話太滿。重量兩份安裝的 24 個 BIGF:項目數那一欄 24 個全部照大端讀才走得完目錄, 但檔案總大小那一欄 24 個裡有 22 個要照小端讀才等於真正的大小。 目錄大小那一欄本站兩種都對不上,沒有定案,不替它下結論。
walk_blocks() 沿著區塊鏈走一遍,回每一塊的位移、標記、長度, 以及「走到第幾個位元組」。長度是小端序, 而且含那 8 個位元組自己。 長度小於 8 會讓位置停在原地變成無窮迴圈, 超過檔尾則是壞檔,兩種都在這裡擋掉。走到 SCEl 就收手, 因為它只負責讀「第一段」。 ⚠️ 2026-09-05 訂正:這裡原本寫「因為它後面通常是封裝檔的補齊, 不是下一個語音」——反了。原版 stdnmdat.big 的 94 個項目裡有 85 個, SCEl 後面還接著完整的語音;pbpdat.big 更是 15,391 段直接一段接一段。所以腳本現在會先數尾巴裡還有幾個 SCHl 開頭再下結論,而不是一律說「那是補齊」。
parse_tags() 把檔頭那串「標籤 + 長度 + 值」讀成一張表。 值是大端序,0xFF 是整串的結束記號。 0xFC / 0xFD / 0xFE 這三個只有標籤本身,後面沒有長度也沒有值。 把它們當成一般標籤去讀會吃掉後面的位元組,整串從那裡開始錯位。
parse_voice() 把上面幾支串起來,回一份描述:子格式、編碼、聲道、 取樣數,以及每一段聲音資料的原始位元組。 標籤從第幾個位元組開始要看子格式: PT 開頭的從第 12 個開始,其餘從第 16 個開始。 這一格算錯整串標籤全歪,而畫面上只會表現成一個 ❌。
describe() 把檔頭印成人看得懂的樣子,同時做第一個硬驗證: 標籤走完的位置對不對得上檔頭宣告的長度。 ❌ 不等於壞檔。本站在自己這台機器的安裝上, 把 data/audio 底下每一個語音都量過一次: 編碼 1 與 2 的 7,724 個全部吻合,編碼 3(社群做的)的 8,767 個裡 有 2,586 個對不上。 ⚠️ 這裡數的單位是「封裝檔項目」,一個項目算一個 (2026-09-05 補單位)—— 換掉遊戲的聲音 那一課對同一個資料夾寫的是「編碼器 3 共 8,909 段」, 它數的是 SCHl 段,一個項目裡可以串好幾段, 而且連不是 BIGF 的那幾個檔也算進去。兩個數字都對,量的不是同一件事。 沒有標取樣率時只替 GSTR 容器的編碼 3 換算秒數, 因為 48,000 Hz 那個推論只涵蓋那一批,而且本站還沒定案 (腳本第三節寫了矛盾在哪)。
split_channels() 用開頭的聲道起點表把一段切成每個聲道。 起點是大端序,而且是從起點表之後算起的。 ⚠️ 「大端序」這一格只在 GSTR 容器上驗過 —— 本站兩份安裝裡的多聲道項目 321 個剛好全部是 GSTR 的編碼 3, 別種容器的多聲道本站沒有樣本可以說。 兩個聲道各自連續一整段,不是交錯的。 按交錯解等於把兩個人的話剪碎再交叉黏起來, 那是這一課走過最長的一段冤枉路。
find_phase() 15 種起點全部試一遍,挑「表頭高半位元組 ≤ 3」 比例最高的那一個。 同一個檔裡不是每一段都從第 0 個位元組開始, 所以每一段都要重新掃。切對本站量到 97.7%, 切錯只有大約 50%(亂猜的水準),差距大到不會是碰巧對上的。 ⚠️ 那個 97.7% 只在 GSTR 容器的編碼 3 上成立 (本站量到這一類的中位數 1.000);PT 容器的編碼 3 本站量到中位數 0.478,就是亂猜的水準。
show_blocks() --blocks 才會跑:每一段切成聲道、 再切成 15 個位元組一組,把量到的數字攤開。 遇到編碼 2 直接說「本站只解開了 GSTR 容器的編碼 3 的擺法」然後停手; 遇到裝在 PT 容器裡的編碼 3 也一樣停手(同一個理由)。 不硬套一套切法、印出一堆看起來很像真的數字。

它沒有 --apply、沒有備份、也沒有還原功能, 因為它從頭到尾沒有任何東西可以被改壞:開檔一律 'rb', 全檔沒有一個 open(..., 'w'),你可以自己搜一次確認。 做不到的事寫在腳本開頭:不產生 WAV、也不把你的聲音寫回去 (那兩件事在換掉遊戲的聲音那一課), 只認 SCHl 開頭的語音檔, 而聲音資料的切法只在 GSTR 容器的編碼 3 上驗過。

想先確認這支腳本本身沒壞,可以跑 python3 -I mvp_read_voice.py --selftest (Windows 打 python)。它不需要遊戲檔:拿程式裡拼出來的假語音檔, 把這支的每一道守門各下一個餌驗一次,全程在記憶體裡,一樣不寫檔、不建資料夾。 全過回 0,有一道沒過回 1。

完整原始碼

下面就是你剛才下載的那一支,一個字都沒有不同(本站有自動檢查在守這件事)。 它完全不會寫入任何檔案 —— 沒有任何寫檔呼叫,你可以自己搜一次確認。

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

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

"""
mvp_read_voice.py —— 讀懂 MVP Baseball 2005 的語音檔(唯讀,不會改任何東西)

    列出遊戲裡的語音   python3 -I mvp_read_voice.py "<遊戲資料夾>" --list
    看單一語音檔       python3 -I mvp_read_voice.py "<某個 .dat>"
    看內部區塊切法     python3 -I mvp_read_voice.py "<某個 .dat>" --blocks
    自我測試           python3 -I mvp_read_voice.py --selftest    (不需要遊戲檔)

⚠️ 這支腳本**不會**把語音變成 WAV —— 這一課只負責讀懂結構。
   要匯出 WAV 或把你自己的聲音寫回去,用 make-voice 那一課的 mvp_audio.py。

─────────────────────────────────────────────────────────
 這支在做什麼
─────────────────────────────────────────────────────────
一句話:把遊戲的語音檔拆開,把外殼裡量得出來的東西印給你看,全程唯讀。

吃什麼(輸入)
  · 遊戲資料夾 + --list:往下找 data/audio,把每個 **BIGF** 封裝檔裡有幾段語音、
    分別用哪種編碼列出來。找不到 data/audio 就直接掃你給的那一層,
    所以散裝資料夾或單獨一個封裝檔也吃得下去。
    ⚠️ 只認 BIGF 開頭的封裝檔。cd/ 底下那 8 個「本身就是一長串 SCHl」的 .big
    (pbpdat / chantdat / padat / rallydat / plchtdat / hsfxdat / stdmdat /
    bdcstdat)不會出現在 --list 的清單裡 —— 播報語音就在其中最大的那個
    pbpdat.big。要看它們就把檔案本身指給這支腳本。
  · 單一語音檔(.dat,或你從封裝檔裡撈出來的一段):印那一個檔的檔頭。
    ⚠️ 剛安裝好的原版**沒有散裝的 .dat**(本站數過原版 data/audio 底下 82 個
    非 .big 的檔,.dat 是 0 個),語音全部包在 .big 裡;散裝的 .dat 多半來自
    社群語音包。這支沒有取出功能,要把某一段撈成 WAV 是 make-voice 那一課的
    mvp_audio.py --export;上面說的那 8 個 .big 則可以直接指給這支腳本。
  · 再加 --blocks:連聲音資料內部怎麼切成聲道、怎麼切成 15 個位元組一組都印出來。

吐什麼(輸出)
  · 只有畫面上的文字。它不寫檔、不建資料夾、不產生 WAV。
    整支腳本開檔一律是 'rb',沒有任何 open(..., 'w'),你可以自己搜一次確認。
  · 回傳值:讀得懂是 0;看單一檔案時,讀不懂、找不到檔案、路徑給的是資料夾卻沒加
    --list、或沒有權限讀那個檔,這四種都印一句人話然後回 2(不丟 traceback)。
    --list 遇到讀不到的 .big 則是點名跳過,清單照樣列完、回 0(2026-09-26 加)。
  · --selftest(2026-09-24 加):在記憶體裡拿自己拼的假語音檔,把每一道守門驗一次,
    一樣只印字、不開你的任何檔案。全過回 0,有一道沒過回 1,在 python3 -O 底下拒跑回 2。

安全網
  · 唯讀是它的預設,也是它唯一的模式:沒有 --apply、沒有備份、也沒有還原功能,
    因為它從頭到尾沒有任何東西可以被改壞。
  · 讀不懂就停下來(丟 DataError),不硬解:開頭不是 SCHl、區塊長度不合理,
    這兩種會印一句人話然後結束(回傳 2)。--list 掃整包時則是跳過讀不懂的那一項
    (整個 .big 讀不到的話,點名那一個再跳過),
    --blocks 遇到某一段切不開也只會說那一段切不開,其餘照樣走完。
  · 印出來的東西分「量到的」與「推論」:取樣率 48,000 Hz 是推論,
    而且只涵蓋 GSTR 容器的編碼 3(社群做的那批裡的一種)。EA 原版、以及
    PT 容器的編碼 3,本站都測不出來,所以不替它們換算秒數。
    而且那個推論**本身也還沒定案**,第三節那個 ⚠️ 寫了矛盾在哪。

做不到的事
  · 不會把語音變成 WAV,也不會把你的聲音寫回去(那兩件事在 make-voice 那一課)。
  · 只認 SCHl 開頭的語音檔。BIGF 封裝檔只列目錄,不解裡面的圖或別的資料。
  · 聲音資料的切法只在 **GSTR 容器的編碼 3** 上驗過。遇到別的(編碼 1、編碼 2、
    以及 PT 容器的編碼 3)只印外殼,--blocks 會直接說本站沒解開然後停手,
    而不是套一套印出看起來像真的數字。

─────────────────────────────────────────────────────────
 為什麼會有這一課
─────────────────────────────────────────────────────────
本站原本把「換播報語音」整條路標成做不到,理由是「音訊編碼沒解開」。
**那是把「未解」貼在錯的格子。**社群二十年前就換成功了 ——
他們需要的是「產生一個遊戲吃得下去的檔」,不是「把 EA 的解碼出來」。

這一課是往那個方向的第一步:先確認我們真的讀得懂外殼。
結果是外殼讀懂了,最後一格當時還沒 —— 那一格 2026-08-29 也解開了,見下面第四節。
下面每個數字都可以自己重跑。

─────────────────────────────────────────────────────────
 一、外殼(完全解開)
─────────────────────────────────────────────────────────
語音檔是 EA 的區塊鏈:

    SCHl  檔頭 + 一串標籤
    SCCl  計數
    SCDl  聲音資料(可以有很多個)
    SCEl  結束

每個區塊 = 4 bytes 標記 + 4 bytes 長度(小端序)。
剛安裝好的原版 6,995 個語音檔,區塊鏈 100% 完整。

檔頭標籤是「標籤(1) + 長度(1) + 值」,但 0xFC/0xFD/0xFE 沒有長度也沒有值:

  0x06  恆為 101(6,995 個全部一樣)
  0x80  編碼方式    EA 原版 = 2 · 社群做的 = 3
  0x82  聲道數(本站這台機器上,編碼 3 的 8,767 個裡 8,446 個是 1、321 個是 2;
        下面第二節那套雙聲道擺法講的是那 321 個。編碼 1 與 2 全部是 1)
  0x84  取樣率(分編碼差很多:剛安裝好的原版 6,995 個一個都沒標;本站這台機器上
        編碼 2 的 7,271 個裡 207 個有標,編碼 3 的 8,767 個裡 8,230 個有標,
        其中 7,841 個標 22,050 Hz)
  0x85  取樣數
  0xa0  恆為 4。但「只有 EA 原版有」是錯的:本站兩份安裝上量到 PT 容器的
        15,283 個項目**全部**有這個標籤,GSTR 容器的 8,203 個裡只有 11 個有
        —— 它比較像「PT 容器的記號」,不是「EA 原版的記號」。

驗證方式很硬:**標籤解析消耗的長度必須等於檔頭宣告的長度** ——
原版 6,995 個檔全部落在檔頭宣告長度的 1 個位元組容差內(程式第 465 行就是這樣判的:2,556 個剛好相等、4,439 個差 1)。

─────────────────────────────────────────────────────────
 二、聲音資料怎麼擺(完全解開)
─────────────────────────────────────────────────────────
每個 SCDl 的內容是:

    [4 bytes] 這一段的取樣數(位元組序看容器:PT 小端、GSTR 大端)
    [4 bytes] 左聲道起點(永遠 0)
    [4 bytes] 右聲道起點
    [......]  左聲道一整段
    [......]  右聲道一整段

**兩個聲道不是交錯的,是各自連續一整段。**
驗證:右聲道起點必須正好等於「剩下資料的一半」——
社群檔在這台機器上 321 個雙聲道檔、2,382 段全部吻合(可以自己重跑)。

(本站原本按「左 15 右 15 交錯」解,等於把兩邊剪碎後交叉黏起來。
  這是這一課走過最大的一段冤枉路。)

每個聲道裡面是 **15 個位元組一組**:1 個表頭 + 14 個資料位元組(= 28 個半位元組)。
表頭的高半位元組是 0–3(四組係數之一)。驗證方式是**掃相位**:
切在對的位置時「高半位元組 ≤ 3」占 **97.7%**,切錯位置時只有約 50%
(= 亂猜的水準)。差距這麼大,切法就不可能是猜的。

⚠️ **這一整節只在「GSTR 容器」的編碼 3 檔上成立**(2026-09-05 量到的分界)。
   編碼 3 有兩種容器,行為完全相反:
     · GSTR(本站這台機器 8,203 個):掃相位中位數 1.000、98.0% 的聲道 >0.9,
       會印「⚠️ 切法可能不對」的只有 2.2% 的檔。上面那些數字講的就是這一批。
     · PT(564 個):掃相位中位數 0.478 —— 就是亂猜的水準,**100% 的檔都會印
       「⚠️ 切法可能不對」**。而且算得出來根本擺不下:某一段宣告 1,728 個取樣,
       照 15/28 需要 915 個位元組,那一段只有 668 個。
   所以 PT 容器的編碼 3 是**另一種東西,本站沒有解開**,--blocks 對它直接說
   自己沒解開,不套一套印出看起來像真的數字。

資料半位元組的分佈也對得上 4-bit 差分編碼:對稱、峰在 0/1/2/E/F、
谷在 7/8、位元組熵 6.5 bits(隨機資料會是 8)。

段落長度的迴歸:**位元組 = 1.0697 × 取樣數 + 每段固定開銷**,
而每聲道 15 bytes / 28 取樣 × 2 聲道 = 1.0714。差 0.16%。

─────────────────────────────────────────────────────────
 三、取樣率 48,000 Hz(推論,證據夠強但不是直接量到)
─────────────────────────────────────────────────────────
大部分檔沒有標 0x84。舊資料裡有 681 個來源 WAV(當年錄語音的素材),
其中 **680 個是 48,000 Hz**(剩下那一個是 24 kHz 的 MP3,不是 PCM)。
這批裡有一部分的**取樣數跟語音檔精確相同**,
名字也對得上(`Tien.wav` ↔ 田家安的 4518)。

**做過隨機對照**:把語音檔的取樣數整體平移一個隨機量、跑 200 次,
最多只吻合 60 個。實際 188 個高於 200 次對照的最大值 —— 配對是真的。

取樣數完全相同 = 沒有重新取樣 → 語音檔也是 48,000 Hz。
⚠️ 這一步是**推論**:直接的證據是「長度一樣」,不是「內容一樣」。

⚠️ 這個結論只涵蓋**那一批社群檔**。EA 原版(編碼 2)沒有標 0x84,本站測不出來。

⚠️ **這一格本站還沒定案(2026-09-05 重跑時抓到的矛盾)**:拿 680 個 48 kHz 來源
   WAV 的每聲道取樣數去配語音檔,161 個 WAV 配到 195 個語音檔,而那 195 個裡
   有 **96 個是編碼 3、而且自己標著 22,050 Hz**,只有 10 個沒有標。
   「語音檔也是 48,000 Hz」跟「檔案自己標的取樣率」最多只能對一個。
   本站沒有量出哪一個對(試過拿解出來的取樣跟來源 WAV 比包絡:真配對 6 個
   平均 0.27、隨機對照平均 0.14 但最大值 0.54 —— 那個指標分不開兩者),
   所以兩邊都照原樣印給你看,不替你選一邊。
   另外,「大部分檔沒有標 0x84」這句話在編碼 3 上其實反過來:本站這台機器上
   編碼 3 的 8,767 個裡 8,230 個有標,沒標的 537 個裡有 534 個是 **PT 容器**
   —— 也就是第二節那個 ⚠️ 說「本站沒解開」的那一批。所以這支腳本現在只對
   GSTR 容器的編碼 3 印那句「若是 48000 Hz 則為 X 秒」。

─────────────────────────────────────────────────────────
 四、這一格後來解開了(2026-08-29 訂正)
────────────────────────────────────
**15 個位元組怎麼變回 28 個取樣的還原公式 —— 已經解開。**

這一節原本寫「本站沒有解開的(就這一格)」:試過 1,000 種以上的參數組合
(位移基準、半位元組順序、係數表、濾波器變體、前置長度),
最好的相關係數只有 0.16。那段經過是真的,結論已經過期。

解開它的不是更聰明的組合,是**找到一份標準答案** —— 免費的 ffmpeg
解得開遊戲的選單音樂,於是「你錯了」終於有東西說得出口。
對照驗證 21,678,328 個取樣 0 個不同,編碼寫回去再讀出來 98.1 dB。

**這支腳本仍然只讀不寫、不產生 WAV**,那是分工不是做不到。
要匯出 WAV 或把自己的聲音寫回去,用「換掉遊戲的聲音」那一課的
mvp_audio.py(--export / --import)。

MIT License · Copyright (c) 2026 toni · 無外部相依,Python 3.7 以上
"""
TOOL_DATE = '2026-10-30'  # 這一版工具的日期

import argparse
import os
import struct
import sys

# 編碼 3 的一組 = 1 個表頭位元組 + 14 個資料位元組(= 28 個半位元組),
# 還原出來是 28 個取樣。這兩個數字撐起後面的相位掃描與長度換算,
# 改動它們等於改掉本站對這個格式的整套理解。
BLOCK = 15          # 一組 15 個位元組
PER_BLOCK = 28      # 還原成 28 個取樣


class DataError(Exception):
    """看不懂這個檔就丟這個。

    單獨看一個檔時,main() 會把它印成一句人話(不丟一整片 traceback)。
    --list 掃整包時反過來:讀不懂的那一項跳過就好,不要讓整份清單停下來。
    """
    pass


# ─────────────────────────────────────────────────────────
#  BIGF 封裝檔(只列目錄,不寫回)
# ─────────────────────────────────────────────────────────

def big_entries(raw):
    """列出 BIGF 封裝檔的目錄,回傳 [(名稱, 位移, 大小)]。不是 BIGF 就回 None。

    檔頭固定 16 個位元組:BIGF(4) + 檔案總大小(4) + 項目數(4) + 目錄大小(4),
    ⚠️ **不是每一欄都同一個方向**,別照抄「數字全是大端序」那句話(舊版寫錯了):
      · 項目數(raw[8:12])是**大端序** —— 這支只用這一欄,本站兩份安裝的 24 個
        BIGF 檔照大端讀都走得完整份目錄。它跟裡面語音區塊的長度(小端序)方向相反,
        這一格搞反不會當掉,只會安靜地讀出一堆天文數字。
      · 檔案總大小(raw[4:8])多數是**小端序**:24 個裡 22 個照小端讀才等於
        真正的檔案大小,只有 2 個是大端。
      · 目錄大小(raw[12:16])本站沒有定案 —— 拿它跟「目錄實際結束的位置」比,
        大端只對上 3 個、小端 0 個,所以這裡不替它下結論。
    """
    if raw[:4] != b'BIGF':
        return None
    count = struct.unpack('>I', raw[8:12])[0]
    # 目錄從第 16 個位元組開始,一項是「位移(4) + 大小(4) + 名稱」。
    # 名稱沒有長度欄,是用一個 0x00 收尾,所以要往前找那個 0 才知道下一項在哪。
    out, p = [], 16
    for _ in range(count):
        # 目錄被截斷時就停在這裡。項目數是檔案自己宣告的,不可以無條件相信。
        if p + 8 > len(raw):
            break
        off, size = struct.unpack('>II', raw[p:p + 8])
        p += 8
        # 名稱是 0x00 收尾。找不到那個 0 就代表目錄在這裡被截斷了 —— 用 find 不用
        # index:index 會丟 ValueError,而這裡在呼叫端的 try 之外,整份清單會當場停掉
        # (檔頭那句「跳過讀不懂的那一項」就變成假的)。
        e = raw.find(b'\x00', p)
        if e < 0:
            break
        # 名稱按 latin-1 解:這一層要的是「位元組原樣對上字元」,不是正確的文字,
        # 用它才不會有任何位元組解不開而中途炸掉。
        out.append((raw[p:e].decode('latin-1'), off, size))
        p = e + 1
    return out


# ─────────────────────────────────────────────────────────
#  SCHl 容器
# ─────────────────────────────────────────────────────────

# 這三個標籤只有標籤本身,後面沒有長度也沒有值。
# 把它們當成「標籤 + 長度 + 值」去讀,會吃掉後面的位元組,整串標籤從這裡開始錯位。
NOVALUE = (0xFC, 0xFD, 0xFE)
# 0x80 這個標籤標的是編碼方式。三個值本站都遇得到,括號裡寫的是各自的解開進度。
CODEC = {1: '音樂那種,每塊自帶起始狀態 —— 已解開,見 make-voice',
         2: 'EA 原版用的 —— 已解開,見 make-voice 那一課',
         3: '社群做的用的 —— GSTR 容器的那種已解開,見 make-voice 那一課'}


def walk_blocks(b):
    """沿著區塊鏈走一遍,回傳 [(位移, 標記, 長度)] 與「走到第幾個位元組」。

    一個區塊 = 4 個位元組的標記(SCHl / SCCl / SCDl / SCEl)+ 4 個位元組的長度,
    長度是**小端序**,而且**含這 8 個位元組自己**,所以下一塊的位移就是 i + n。
    """
    out, i = [], 0
    while i + 8 <= len(b):
        tag = b[i:i + 4]
        # 標記不是 SC 開頭就代表區塊鏈到此為止。這裡用 break 而不是丟錯,
        # 因為呼叫端要靠回傳的位置去算「後面還剩多少不屬於區塊鏈的位元組」。
        if tag[:2] != b'SC':
            break
        n = struct.unpack('<I', b[i + 4:i + 8])[0]
        # 兩種壞法一起擋:長度小於 8 會讓 i 停在原地變成無窮迴圈,
        # 超過檔案尾則是壞檔或位元組序讀反了。硬走下去會讀到別人的資料。
        if n < 8 or i + n > len(b):
            raise DataError('第 %d 個位元組的區塊長度 %d 不合理' % (i, n))
        out.append((i, tag.decode('latin-1'), n))
        i += n
        # SCEl 是結束標記。它後面**可能**是封裝檔的補齊,也可能是下一段語音:
        # 原版 cd/spch_pbp/pbpdat.big 就是 15,391 段 SCHl 直接接在一起(本站走過
        # 每一段,15,391 段都以 SCEl 收尾)。這支走到這裡就收手,只讀第一段 ——
        # 所以呼叫端要拿回傳的位置去看「後面還剩什麼」,不要預設那是補齊。
        if tag == b'SCEl':
            break
    return out, i


def parse_tags(b, start, end):
    """把檔頭裡那串標籤讀成 {標籤: 值},並回傳讀到第幾個位元組。

    一筆是「標籤(1) + 長度(1) + 值(長度個位元組)」,值是**大端序**
    (跟區塊長度的小端序相反)。0xFF 是整串的結束記號。
    end 是檔頭自己宣告的長度:讀到那裡就停,不要越界讀進聲音資料。
    回傳的位置是硬驗證用的,呼叫端會拿它跟宣告長度比對。
    """
    out, i = {}, start
    while i < end:
        t = b[i]
        if t == 0xFF:
            i += 1
            break
        # 0xFC/0xFD/0xFE 沒有長度也沒有值,只往前走一格。
        # 用 setdefault 是為了同一個標籤出現兩次時不覆蓋先讀到的那筆。
        if t in NOVALUE:
            out.setdefault(t, None)
            i += 1
            continue
        # 長度欄或值被宣告的長度切掉 = 這個檔頭沒讀完就到底了。
        # 這裡安靜停手,呼叫端會發現 tag_end 對不上 hdr_len,在畫面上打 ❌。
        if i + 1 >= end:
            break
        n = b[i + 1]
        if i + 2 + n > end:
            break
        out[t] = int.from_bytes(b[i + 2:i + 2 + n], 'big')
        i += 2 + n
    return out, i


def parse_voice(b):
    """回傳一個 dict 描述這個語音檔。看不懂就丟 DataError。

    走法:先確認開頭是 SCHl,沿區塊鏈走一遍,再回頭讀第一塊(檔頭)裡的標籤。
    ⚠️ 標籤從第幾個位元組開始要看子格式:第 8 到 11 個位元組是子格式標記,
       PT 開頭的從第 12 個開始,其餘從第 16 個開始。這一格算錯,整串標籤全歪。
    """
    if b[:4] != b'SCHl':
        raise DataError('開頭不是 SCHl,這不是語音檔')
    blocks, consumed = walk_blocks(b)
    # 第一塊一定是檔頭,它宣告的長度就是標籤那串可以讀到哪裡為止。
    # 開頭是 SCHl 卻一個區塊都走不出來 = 檔案短到連 8 個位元組的區塊頭都放不下。
    # 不擋的話下一行 blocks[0] 會丟 IndexError,使用者拿到的是一整片 traceback。
    if not blocks:
        raise DataError('只有 %d 個位元組,連一個完整的區塊都放不下' % len(b))
    hdr_len = blocks[0][2]
    sub = b[8:12]
    tags, tag_end = parse_tags(b, 12 if sub[:2] == b'PT' else 16, hdr_len)

    # 聲音資料可以拆成很多個 SCDl,一段接一段。
    # 每個 SCDl 的 pos+8 到 pos+12 是這一段的取樣數,pos+12 之後才是內容。
    # ⚠️ **那個取樣數的位元組序跟容器綁在一起**,不是固定的(這一格本站 2026-09-05
    #    才補上,在那之前一律當大端讀,所以每一個 PT 容器的檔都讀出天文數字):
    #      PT   容器 → 小端序
    #      GSTR 容器 → 大端序
    #    驗證法很硬:把一個檔每一段的取樣數加起來,應該等於檔頭 0x85 標的總數。
    #    本站在兩份安裝上量了 23,486 個項目 —— 照容器選位元組序時全部相符,
    #    照舊的「一律大端」則 PT 那 8,288 個沒有一個對得上。
    #    (這條規則跟 make-voice 那一課 2026-08-30 量到的是同一條。)
    # 這裡只收「取樣數 + 原始位元組」,怎麼切成聲道交給 split_channels,
    # 因為那件事只在 GSTR 容器的編碼 3 上驗過,不該在這一層先套上去。
    endian = '>' if sub[:4] == b'GSTR' else '<'
    chunks = []
    for pos, tag, n in blocks:
        if tag == 'SCDl':
            chunks.append((struct.unpack(endian + 'I', b[pos + 8:pos + 12])[0],
                           b[pos + 12:pos + n]))

    return {'sub': sub.decode('latin-1').rstrip('\x00'),
            # 容器種類要一路帶到輸出:它決定位元組序,也決定下面那套
            # 「15 個位元組一組」的擺法適不適用(只有 GSTR 適用)。
            'gstr': sub[:4] == b'GSTR', 'endian': endian,
            'hdr_len': hdr_len, 'tag_end': tag_end, 'tags': tags,
            'blocks': [(t, n) for _, t, n in blocks], 'chunks': chunks,
            'consumed': consumed, 'total': len(b),
            'codec': tags.get(0x80), 'channels': tags.get(0x82, 1),
            'samples': tags.get(0x85), 'rate': tags.get(0x84)}


def split_channels(data, channels):
    """用聲道起點表把一段切成每個聲道。回傳 [(起點, 位元組)]。

    傳進來的 data 是 SCDl 扣掉開頭那 4 個位元組(取樣數)之後的部分。
    它的開頭是每個聲道各 4 個位元組的起點(大端序),第一個永遠是 0,
    而且起點是從「起點表之後」算起,所以真正的位置要再加上 4 × 聲道數。
    ⚠️ 「大端序」這一格只在 **GSTR 容器**上驗過,而本站兩份安裝裡的多聲道項目
       (321 個)剛好全部都是 GSTR 的編碼 3,所以另一種容器的多聲道長什麼樣子
       本站沒有樣本可以說。呼叫端(show_blocks)也只讓 GSTR 的編碼 3 走到這裡。
    **兩個聲道各自連續一整段,不是交錯的**。
    按交錯解等於把兩個人的話剪碎再交叉黏起來,那是本站走過最長的一段冤枉路。
    """
    if channels < 1:
        raise DataError('聲道數 %s 不合理' % channels)
    offs = [struct.unpack('>I', data[4 * i:4 * i + 4])[0] for i in range(channels)]
    base = 4 * channels
    out = []
    for c in range(channels):
        # 下一個聲道的起點就是這個聲道的結尾;最後一個聲道沒有下一個,吃到資料尾。
        s = base + offs[c]
        e = base + offs[c + 1] if c + 1 < channels else len(data)
        out.append((s, data[s:e]))
    return out


def find_phase(body):
    """掃相位:切在對的位置時,表頭高半位元組應該幾乎都 ≤ 3。

    為什麼要掃:同一個檔裡不是每一段都從第 0 個位元組開始
    (本站量到同一個檔第 1 段前面有 3 個位元組前置、第 2 段沒有),
    所以每一段都要重新找 15 個位元組那一組的起點,不能沿用上一段的。
    判準:表頭的高半位元組是 0 到 3(四組係數之一)。切對的位置本站量到 97.7%,
    切錯只有大約 50%(= 亂猜的水準)。差距這麼大,就不會是碰巧對上的。
    ⚠️ 那個 97.7% 只在 **GSTR 容器**的編碼 3 上成立(本站量到中位數 1.000);
       PT 容器的編碼 3 中位數 0.478,就是亂猜的水準,所以 show_blocks 根本
       不讓它走到這裡(見腳本開頭第二節那個 ⚠️)。
    ⚠️ 回傳的分數是 **-1.0** 時代表「這個聲道連一組都放不下,量不出來」,
       不是 -100%。呼叫端要先判 sc < 0,直接乘 100 去印會出現負的百分比。
    """
    best, score = 0, -1.0
    # 15 個位元組一組,所以起點只有 0 到 14 這 15 種可能,全部試一遍就是窮舉。
    for ph in range(BLOCK):
        # 終點是 len(body) - BLOCK + 1:最後一個**完整**的一組起點就是 len - 15,
        # 而 range 的終點不含,所以要 +1。少了那個 +1 會漏掉最後一組 ——
        # 本站量到 68,525 個編碼 3 聲道裡,有 8,497 個的百分比因此算錯、
        # 545 個連相位都選錯,而且 show_blocks 印的「N 組」數的是含最後一組的,
        # 兩個數字本來就對不起來。
        idx = range(ph, len(body) - BLOCK + 1, BLOCK)
        n = 0
        ok = 0
        for i in idx:
            n += 1
            if (body[i] >> 4) <= 3:
                ok += 1
        if n and ok / float(n) > score:
            best, score = ph, ok / float(n)
    return best, score


# ─────────────────────────────────────────────────────────
#  輸出
# ─────────────────────────────────────────────────────────

def describe(b, name):
    """把一個語音檔的檔頭印成人看得懂的樣子,順便把解析結果回傳給 --blocks 用。"""
    v = parse_voice(b)
    # 硬驗證:標籤一路讀下來停的位置,要等於檔頭自己宣告的長度(留 1 個位元組容差)。
    # 差得更多就打 ❌,代表這個檔的標籤沒有照本站理解的方式排。
    # ⚠️ ❌ 不等於壞檔:本站在自己這台機器的安裝上,把 data/audio 底下每一個語音都量
    #    過一次,編碼 1 與 2 的 7,724 個全部吻合,編碼 3(社群做的)的 8,767 個裡
    #    有 2,586 個對不上。上面第一節那個「原版 6,995 個零例外」講的是原版,不含這批。
    fit = '✅' if v['tag_end'] in (v['hdr_len'], v['hdr_len'] - 1) else '❌'
    print('  %s' % name)
    print('    子格式      %s' % v['sub'])
    print('    檔頭        宣告 %d · 標籤解析到 %d  %s' % (v['hdr_len'], v['tag_end'], fit))
    print('    編碼方式    %s → %s' % (v['codec'], CODEC.get(v['codec'], '本站沒見過這個值')))
    # 編碼 3 有兩種容器,只有 GSTR 那種解開了。上面那句話對 PT 容器的檔會誤導,
    # 所以這裡補一行說清楚 —— 「子格式」那一行印的就是它是哪一種。
    if v['codec'] == 3 and not v['gstr']:
        print('                ⚠️ 但你這個是 %s 容器,本站沒解開這一種'
              % (v['sub'] or '(沒有標記)'))
    print('    聲道        %d' % v['channels'])
    print('    取樣數      %s' % (v['samples'] if v['samples'] else '(沒有標)'))
    if v['rate']:
        print('    取樣率      %d Hz(檔案自己標的)' % v['rate'])
        if v['samples']:
            print('    時長        %.2f 秒' % (v['samples'] / float(v['rate'])))
    else:
        print('    取樣率      (沒有標)')
        # 48000 這個推論的來源是「社群做的那批(編碼 3)裡有一部分的取樣數跟
        # 48 kHz 來源 WAV 精確相同」,而那一批是 **GSTR 容器**的。
        # ⚠️ 2026-09-05 加的兩道限制:
        #    (1) 只對 GSTR 的編碼 3 印 —— 沒標 0x84 的編碼 3 檔本站量到 537 個,
        #        其中 534 個是 PT 容器,而 PT 容器的編碼 3 本站根本沒解開,
        #        對它套 48 kHz 等於把一個沒驗過的假設套到別的東西上。
        #    (2) 就算是 GSTR,這個推論本身也還沒定案(見開頭第三節那個 ⚠️:
        #        配對到的檔多數自己標著 22,050 Hz),所以句子裡要寫明白。
        if v['samples'] and v['codec'] == 3 and v['gstr']:
            print('                若是 48000 Hz 則為 %.2f 秒'
                  '(推論,尚未定案 —— 見腳本開頭第三節那個 ⚠️)'
                  % (v['samples'] / 48000.0))
        elif v['samples'] and v['codec'] == 3:
            print('                本站沒解開 %s 容器的編碼 3,所以不換算秒數'
                  % (v['sub'] or '(沒有標記)'))
        elif v['samples']:
            print('                本站測不出編碼 %s 的取樣率,所以不換算秒數' % v['codec'])
    # 區塊多的時候不要整串印出來(本站在自己的安裝上遇過一個檔 54 塊),只給頭尾與總數,
    # 但「其中幾塊是聲音資料」要留著:那個數字決定 --blocks 會印出幾段。
    bl = v['blocks']
    if len(bl) <= 8:
        print('    區塊        %s' % ', '.join('%s(%d)' % x for x in bl))
    else:
        nd = sum(1 for t, _ in bl if t == 'SCDl')
        print('    區塊        %s, …, %s(共 %d 塊,其中 %d 塊是聲音資料)'
              % (', '.join('%s(%d)' % x for x in bl[:3]),
                 '%s(%d)' % bl[-1], len(bl), nd))
    # 各段開頭那 4 個位元組 = 取樣數。位元組序照容器選(見 parse_voice 那一段)之後,
    # 這個加總本站在兩份安裝的 23,486 個項目上都等於檔頭 0x85 標的總數 ——
    # 所以現在每一種編碼都比得了,不再只比編碼 3。
    # (舊版一律用大端讀,PT 容器的那 8,288 個會加出天文數字,所以當時只好不比。)
    tot = sum(c for c, _ in v['chunks'])
    if v['samples']:
        print('    各段取樣加總 %d %s 標籤說的 %d'
              % (tot, '=' if tot == v['samples'] else '≠', v['samples']))
    if v['consumed'] != v['total']:
        # ⚠️ 這段尾巴**不一定是補齊**,所以先數一下再下結論:
        #    剛安裝好的原版 stdnmdat.big 的 94 項全部有尾巴,其中 85 項的尾巴裡
        #    還接著完整的 SCHl 語音串(那 94 項的位移互不重疊,所以確實屬於同一項);
        #    cd/spch_pbp/pbpdat.big 更極端 —— 15,391 段語音接在一起,這支只讀第一段,
        #    剩下的 212,216,048 個位元組全落在這個「尾巴」裡。
        tail = b[v['consumed']:v['total']]
        more = tail.count(b'SCHl')
        if more:
            print('    區塊鏈之後還有 %d 個位元組,裡面還有 %d 個 SCHl 開頭'
                  '(那是後面還沒讀的語音,不是補齊;這支只讀第一段)'
                  % (len(tail), more))
        else:
            print('    區塊鏈之後還有 %d 個位元組(找不到別的 SCHl,'
                  '看起來是封裝檔的補齊)' % len(tail))
    return v


def show_blocks(v):
    """--blocks:把每一段切成聲道、再切成 15 個位元組一組,把量到的數字攤開。"""
    print()
    print('  ── 聲音資料怎麼擺 ──')
    # 只有 **GSTR 容器的編碼 3** 這一套擺法本站驗過(社群語音包 1,945 段零例外;
    # 2026-09-05 用這台機器全部 321 個雙聲道檔的 2,382 段重跑,也全部吻合)。
    # 別的硬套同一套切法,印出來的數字會看起來很像真的,但沒有東西支持它。
    if v['codec'] != 3:
        print('  這個檔是編碼 %s,本站只解開了 GSTR 容器的編碼 3 的擺法。' % v['codec'])
        return
    # 編碼 3 有兩種容器,只有 GSTR 那種本站解開了。PT 容器的編碼 3(本站這台機器
    # 564 個)掃相位中位數 0.478 = 亂猜的水準,100% 的檔都會印「⚠️ 切法可能不對」,
    # 而且照 15/28 算出來的位元組數比實際有的還多(1,728 取樣要 915 bytes,只有 668)。
    # 硬印出來的數字看起來很像真的,但沒有東西支持它 —— 跟上面擋掉別的編碼同一個理由。
    if not v['gstr']:
        print('  這個檔是編碼 3,但裝在 %s 容器裡(不是 GSTR)。' % (v['sub'] or '(沒有標記)'))
        print('  本站解開的擺法只涵蓋 GSTR 那一種,所以這裡不套上去。')
        print('  (量到的分界:本站這台機器 GSTR 8,203 個、掃相位中位數 1.000;')
        print('    PT 564 個、中位數 0.478 —— 那就是亂猜的水準。)')
        return
    for n, (cnt, d) in enumerate(v['chunks'], 1):
        try:
            chans = split_channels(d, v['channels'])
        except Exception as e:
            print('  第 %d 段:切不開(%s)' % (n, e))
            continue
        # 這是這一格的硬驗證:扣掉起點表之後,每個聲道應該一樣長
        # (兩聲道時就是「右聲道起點 = 剩下資料的一半」)。
        # 對不上代表切法或聲道數讀錯了,所以印 ⚠️ 而不是安靜地繼續。
        half = (len(d) - 4 * v['channels']) // v['channels']
        mark = '✅' if all(len(x[1]) == half for x in chans) else '⚠️'
        print('  第 %d 段  宣告 %d 取樣 · %d bytes · 每聲道 %d bytes  %s'
              % (n, cnt, len(d), half, mark))
        for c, (start, body) in enumerate(chans):
            ph, sc = find_phase(body)
            nb = max(0, (len(body) - ph) // BLOCK)
            print('     聲道 %d  起點 %-6d 長度 %-6d 前置 %d bytes · %d 組 × 15 bytes'
                  % (c, start, len(body), ph, nb))
            # find_phase 用 -1.0 當「一組都放不下」的記號,直接乘 100 會印 -100.0%,
            # 看起來像程式壞掉。真實資料碰不到(本站量到最短的聲道是 16 bytes),
            # 但壞檔或別人的模組包會。
            if sc < 0:
                print('             表頭高半位元組 ≤3   這個聲道只有 %d bytes,'
                      '不到一組(15 bytes),量不出來' % len(body))
            else:
                print('             表頭高半位元組 ≤3 占 %.1f%%   %s'
                      % (sc * 100, '切法正確' if sc > 0.9 else
                         ('大致正確' if sc > 0.7 else '⚠️ 切法可能不對')))
            # 把「照 15 → 28 換算出來的取樣數」跟「檔案自己宣告的」並排印,
            # 兩個數字對不上就代表前置位元組或切法還有東西沒算進去。
            print('             可還原 %d 取樣(宣告 %d)' % (nb * PER_BLOCK, cnt))
    print()
    print('  ⚠️ 到這裡為止都量得出來。15 bytes 還原成 28 個取樣的公式已於 2026-08-29 解開,\n        在「換掉遊戲的聲音」那一課;這支只讀不寫,不產生 WAV。')


def cmd_list(root):
    """--list:掃資料夾底下的 .big 封裝檔,列出每一個裡面有幾段語音、用哪種編碼。"""
    # 語音住在 data/audio 底下。給錯一層不強迫你重打:找不到那一層就直接掃你給的
    # 那一層,所以散裝資料夾、或已經拆出來的一堆檔,同樣列得出來。
    base = os.path.join(root, 'data', 'audio')
    if not os.path.isdir(base):
        base = root
    found = 0
    unreadable = 0
    for r, _, fs in os.walk(base):
        for f in sorted(fs):
            if not f.lower().endswith('.big'):
                continue
            p = os.path.join(r, f)
            # 一次把整個封裝檔讀進記憶體:後面每一項就能拿目錄裡的位移直接切,
            # 不必為了成千上萬項反覆 seek(本站量到一個封裝檔裡有 12,527 段語音)。
            # 開檔是 'rb',全程唯讀。
            # 讀不到的封裝檔點名跳過(2026-09-26 加)。原本這裡一丟例外整份清單就停掉:
            # 舊版噴一整片 traceback;09-26 第一版改成在 main() 接住,結果印的是
            # 「沒有權限讀這個檔:<你給的資料夾>」—— 把資料夾說成那個檔,也不說是哪一個。
            # 本站在 Mac 上把一個 .big 設成 chmod 000 重現過。跟下面「個別項目讀不懂
            # 不要讓整份清單停下來」同一個道理,路徑照這裡的習慣印相對路徑。
            try:
                with open(p, 'rb') as fh:
                    raw = fh.read()
            except OSError as e:
                unreadable += 1
                print('  %-40s 讀不到,跳過(%s)'
                      % (os.path.relpath(p, root),
                         '沒有讀取權限,或別的程式正鎖著它'
                         if isinstance(e, PermissionError) else '作業系統打不開它'))
                continue
            items = big_entries(raw)
            if not items:
                continue
            codecs = {}
            n = 0
            for _, o, s in items:
                # 封裝檔裡不是每一項都是語音,所以逐項看開頭那 4 個位元組是不是 SCHl。
                if raw[o:o + 4] != b'SCHl':
                    continue
                n += 1
                try:
                    v = parse_voice(raw[o:o + s])
                except DataError:
                    # 個別項目讀不懂不要讓整份清單停下來。這裡是概觀,
                    # 想知道某一個檔為什麼讀不懂,單獨拿那個檔跑一次會印出原因。
                    continue
                codecs[v['codec']] = codecs.get(v['codec'], 0) + 1
            if not n:
                continue
            found += 1
            print('  %-40s %5d 個語音 · 編碼 %s'
                  % (os.path.relpath(p, root), n,
                     ', '.join('%s×%d' % (k, x) for k, x in sorted(codecs.items()))))
    # 有封裝檔讀不到時,清單就不完整 —— 最後再講一次,而且不接著說「請確認資料夾給對了」:
    # 資料夾是對的,問題在那幾個檔(2026-09-26 加)。
    if unreadable:
        print('\n  ⚠️ 有 %d 個 .big 讀不到,上面已經點名跳過,所以這份清單不完整。' % unreadable)
        print('  先確認這個帳號讀得到上面點名的檔(或關掉正在用它的程式),再跑一次。')
    elif not found:
        print('  在這個資料夾底下沒有找到語音封裝檔。')
        print('  請確認你指的是**遊戲安裝資料夾**(它的下一層才是 data)。')


def main():
    """命令列入口:決定要跑 --list 還是看單一檔案,並把讀不懂的狀況印成一句人話。"""
    # Windows 上把輸出導到檔案或接管線(> out.txt、| more、PowerShell 的 |)時,
    # Python 會用系統語系(繁體中文是 cp950)寫 stdout,而 ✅ ❌ ⚠️ ─ 這些字元
    # cp950 沒有 —— 不處理的話會在印到第三行時丟 UnicodeEncodeError、以 1 結束,
    # 只留下半截檔案(本站用 PYTHONIOENCODING=cp950 重現過:31 bytes 就斷了)。
    # 這裡不換編碼,只把「編不出來的那個字」換成問號,中文照舊。
    # 直接看主控台的人不受影響(Python 3.6+ 在 Windows 主控台走 UTF-16 API)。
    try:
        sys.stdout.reconfigure(errors='replace')
    except (AttributeError, ValueError):
        pass
    ap = argparse.ArgumentParser(
        description='讀懂 MVP Baseball 2005 的語音檔(唯讀,不產生音訊)',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='\n這支腳本不會改你的遊戲,也不會產生任何檔案。\n')
    ap.add_argument('path', help='遊戲資料夾(配 --list)或單一 .dat')
    ap.add_argument('--list', action='store_true',
                    help='列出 BIGF 封裝檔裡的語音(cd/ 底下那 8 個本身就是一長串 '
                         'SCHl 的 .big 不在內,例如 pbpdat.big,那種要直接指檔案)')
    ap.add_argument('--blocks', action='store_true', help='顯示聲音資料的內部切法')
    args = ap.parse_args()

    # --list 放在下面那個 try 外面(2026-09-26 改)。下面每一句話講的都是「你給的
    # 那一個檔」(找不到檔案:X、這是一個資料夾、沒有權限讀這個檔:X);--list 給的是
    # 資料夾,放在 try 裡面的話,裡頭某一個 .big 讀不到時就會把資料夾說成那個檔
    # (09-26 第一版就這樣錯過)。讀不到的封裝檔由 cmd_list() 自己點名跳過。
    if args.list:
        cmd_list(args.path)
        return 0
    try:
        # 給的是資料夾就在開檔之前先認出來(2026-09-26 加)。原本只靠下面接
        # IsADirectoryError,但那是 Mac / Linux 的反應;Windows 上 open() 一個資料夾
        # 丟的是 PermissionError(Errno 13),Windows 的讀者會拿到一整片 traceback
        # (這一句是推論:本站還沒在 Windows 上實測)。os.path.isdir 兩邊都認得資料夾,
        # 所以先問它,再丟 IsADirectoryError 交給同一段印同一句話。
        if os.path.isdir(args.path):
            raise IsADirectoryError(21, '這是一個資料夾', args.path)
        with open(args.path, 'rb') as f:
            b = f.read()
        v = describe(b, os.path.basename(args.path))
        if args.blocks:
            show_blocks(v)
        return 0
    # 預期得到的失敗(讀不懂、找不到、給了資料夾、沒有權限讀)各印一句人話就好:
    # 一整片 traceback 對「只是想看看自己遊戲檔」的人沒有任何用處。
    # 回傳 2 代表沒讀成功,別的錯誤仍然照常拋出來。(2026-09-26 從「兩種」改寫:
    # 資料夾那一種 09-24 就在了,這句話沒跟上;這一輪又多了沒有權限那一種。)
    except DataError as e:
        print('\n  停下來了:%s\n' % e)
        return 2
    except FileNotFoundError:
        print('\n  找不到檔案:%s\n' % args.path)
        return 2
    # 路徑給了資料夾卻忘記加 --list 是很自然的失誤(頁面步驟 2 給資料夾、
    # 步驟 3 給檔案)。不擋的話讀者拿到的是 IsADirectoryError 一整片 traceback。
    except IsADirectoryError:
        print('\n  這是一個資料夾,不是單一語音檔。要列出整包語音請加 --list:')
        print('      python3 -I mvp_read_voice.py "%s" --list\n' % args.path)
        return 2
    # 不是資料夾、作業系統卻不讓讀(2026-09-26 加)。資料夾在上面已經先認出來了,
    # --list 也不走這個 try,走到這裡的是你給的那一個檔真的讀不到,所以不說成
    # 「這是資料夾」。跟「找不到檔案」一樣只印你自己給的那個路徑,不印作業系統那串錯誤。
    except PermissionError:
        print('\n  沒有權限讀這個檔:%s' % args.path)
        print('  作業系統不讓這支腳本讀它(這個帳號沒有讀取權限,或別的程式正鎖著它)。\n')
        return 2


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

def selftest():
    """--selftest:拿程式裡拼出來的假語音檔,把這支的每一道守門各驗一次。全過回 0。

    ⚠️ 全程在記憶體裡。假資料是下面拼出來的位元組;要「開檔」的那兩條路
       (--list 走資料夾、單一檔案讀進來)是暫時把 os.walk、os.path.isdir 與這支
       腳本用的 open 換成讀這些位元組的替身,跑完就換回去 —— 所以它跟這支腳本平常一樣:
       不寫檔、不建資料夾,也不讀你電腦上的任何檔案。

    每一道守門都有「餌」(故意餵它該擋的東西,沒擋就紅)與「陰性對照」
    (餵正常的東西,它不該擋;少了這一半,一個什麼都擋的版本也會全綠):
      一、外殼:開頭不是 SCHl、短到放不下一個區塊、區塊長度小於 8、
          區塊長度超過檔尾 —— 四種都要停下來(DataError),不可以噴 traceback
      二、沒有長度的那三個標籤:夾一個 0xFD 在中間,後面的聲道與取樣數照樣讀對
      三、檔頭的硬驗證:標籤讀完的位置對不上宣告的長度時要打 ❌
      四、取樣數的位元組序跟著容器走(PT 小端、GSTR 大端):各段加總要等於標籤
      五、封裝檔目錄被截斷時只列得出來的那幾項、不丟例外;不是 BIGF 就回 None
      六、聲道數小於 1 要停;兩聲道要切成一樣長的兩段
      七、掃相位:連一組都放不下的聲道要回「量不出來」、畫面上不可以印成 -100%;
          剛好一組的聲道要算得出來(2026-09-05 修掉的「少算最後一組」)
      八、--blocks 只對 GSTR 容器的編碼 3 套擺法:PT 容器、編碼 2 都要停手
      九、「若是 48000 Hz」那句推論只對 GSTR 容器的編碼 3 印
      十、區塊鏈之後的尾巴:裡面還有 SCHl 就要說「後面還有語音」,不可以說成補齊
      十一、--list:讀不懂的那一項跳過、不是 BIGF 的 .big 與一段語音都沒有的
          封裝檔都不列,清單照樣列完;一個封裝檔都沒有時要說找不到。
          2026-09-26 加:整個 .big 讀不到(沒有權限、斷掉的連結)→ 點名跳過、
          其餘照樣列完、回 0,不可以把你給的資料夾說成那個檔;唯一的 .big 讀不到時
          不可以說成「沒有找到語音封裝檔」
      十二、命令列:讀不懂、找不到、給了資料夾卻沒加 --list(Mac / Linux 與 Windows
          兩種 open 的反應各用替身丟一次)、不是資料夾卻沒有權限讀
          —— 都印一句話、回 2、不噴 traceback;兩種資料夾印的是同一句
      十三、最上面那道「-O 拒跑」守門自己的餌
    在 python3 -O 底下拒跑,回 2。
    """
    # -O 會把 assert 整段拿掉。這一段的判斷是下面那個 check(),-O 拿不掉它 ——
    # 守門照樣要有:哪天有人在這裡補一句 assert,沒有它就會靜靜地變成假綠。
    if _optimize_level():
        print('--selftest 不能在 python3 -O 底下跑:-O 會把 assert 整段拿掉,'
              '測試會變成一片假的綠燈。請拿掉 -O 再跑一次。')
        return 2
    # 這一行要緊接在守門後面:_optimize_bait() 的陰性對照靠它收工(見那個函式)。
    _opt = _optimize_bait(selftest)
    import contextlib
    import io

    print('自我測試(全程在記憶體裡,不開你的任何檔案、不建資料夾)')
    tally = {'n': 0, 'bait': 0, 'fail': 0}

    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 as e:
            r = e
        return r, buf.getvalue()

    def stops(fn, *a):
        """fn 有沒有照規矩停下來(丟 DataError,不是別的例外)。"""
        return isinstance(quiet(fn, *a)[0], DataError)

    def line_of(text, key):
        return ''.join(x for x in text.splitlines() if key in x)

    def tags(*pairs):
        """把 (標籤, 值的位元組) 串成檔頭那串標籤;值是 None 的是沒有長度的那三種。"""
        out = b''
        for t, v in pairs:
            out += bytes([t]) if v is None else bytes([t, len(v)]) + v
        return out + b'\xff'

    def voice(sub, tag_bytes, chunks, hdr_pad=0):
        """拼一個語音檔:SCHl(檔頭)+ SCCl + 每一段一個 SCDl + SCEl。

        sub 是第 8 到 11 個位元組;GSTR 的標籤從第 16 個開始、PT 從第 12 個開始
        (跟 parse_voice 同一條規則)。chunks 是 [(取樣數, 內容)],取樣數照容器
        選位元組序。hdr_pad 是故意多宣告的檔頭長度(給第三道的餌用)。
        """
        gstr = sub[:4] == b'GSTR'
        body = sub + (b'\x00' * 4 if gstr else b'') + tag_bytes + b'\x00' * hdr_pad
        out = b'SCHl' + struct.pack('<I', 8 + len(body)) + body
        out += b'SCCl' + struct.pack('<II', 12, len(chunks))
        for n, data in chunks:
            out += (b'SCDl' + struct.pack('<I', 12 + len(data))
                    + struct.pack(('>' if gstr else '<') + 'I', n) + data)
        return out + b'SCEl' + struct.pack('<I', 8)

    def groups(n, lead=b''):
        """一個聲道:前置 lead + n 組 15 bytes(表頭 0x21,高半位元組是 2;其餘 0xF7)。"""
        return lead + (b'\x21' + b'\xf7' * 14) * n

    def stereo(left, right):
        """兩聲道:起點表(大端序,0 與左聲道長度)+ 左聲道一整段 + 右聲道一整段。"""
        return struct.pack('>II', 0, len(left)) + left + right

    def tg(codec, ch=None, samples=None, rate=None):
        pairs = [(0x80, bytes([codec]))]
        if ch is not None:
            pairs.append((0x82, bytes([ch])))
        if rate is not None:
            pairs.append((0x84, rate.to_bytes(3, 'big')))
        if samples is not None:
            pairs.append((0x85, samples.to_bytes(4, 'big')))
        return tags(*pairs)

    good = voice(b'GSTR', tg(3, 2, 56), [(56, stereo(groups(2), groups(2)))])
    pt3 = voice(b'PT\x00\x00', tg(3, None, 480), [(480, b'\x00' * 30)])

    print('\n一、外殼')
    v, _ = quiet(parse_voice, good)
    check(isinstance(v, dict) and (v['codec'], v['channels'], v['samples']) == (3, 2, 56),
          '陰性對照:拼好的 GSTR 語音檔讀得懂(編碼 3、2 聲道、56 取樣)')
    # 餌用 SC 開頭但不是 SCHl 的(SCxl):區塊鏈走得下去,只有「開頭要是 SCHl」這一道擋得住它。
    # 換成 RIFF 的話,下一道「一個區塊都走不出來」也會擋,這個餌就測不到它要測的那一道。
    check(stops(parse_voice, b'SCxl' + good[4:]), '開頭是 SC 開頭的別的區塊(不是 SCHl)要停下來', True)
    check(stops(parse_voice, b'SCHl\x00\x00'),
          '只有 6 個位元組、一個區塊都放不下要停下來(不可以丟 IndexError)', True)
    check(stops(parse_voice, b'SCHl' + struct.pack('<I', 4) + good[8:]),
          '區塊長度小於 8 要停下來(不然會停在原地打轉)', True)
    check(stops(parse_voice, b'SCHl' + struct.pack('<I', len(good) + 100) + good[8:]),
          '區塊長度超過檔尾要停下來', True)

    print('\n二、沒有長度的那三個標籤')
    nov = voice(b'GSTR', tags((0x80, b'\x03'), (0xFD, None), (0x82, b'\x02'),
                              (0x85, (56).to_bytes(4, 'big'))),
                [(56, stereo(groups(2), groups(2)))])
    v, _ = quiet(parse_voice, nov)
    check(isinstance(v, dict) and (v['channels'], v['samples']) == (2, 56)
          and v['tag_end'] == v['hdr_len'],
          '0xFD 夾在中間(沒有長度也沒有值),後面的聲道數與取樣數照樣讀對', True)

    print('\n三、檔頭的硬驗證')
    _, out = quiet(describe, voice(b'GSTR', tg(3, 2, 56), [(56, stereo(groups(2), groups(2)))],
                                   hdr_pad=5), 'pad.dat')
    check('❌' in line_of(out, '標籤解析到'), '檔頭多宣告了 5 個位元組,要打 ❌', True)
    _, out = quiet(describe, good, 'good.dat')
    check('✅' in line_of(out, '標籤解析到'), '陰性對照:剛好對得上就打 ✅')

    print('\n四、取樣數的位元組序跟著容器走')
    _, out = quiet(describe, voice(b'PT\x00\x00', tg(3, None, 300),
                                   [(100, b'\x00' * 30), (200, b'\x00' * 30)]), 'pt.dat')
    check('各段取樣加總 300 = 標籤說的 300' in out, 'PT 容器的各段照小端讀,加總等於標籤的 300', True)
    _, out = quiet(describe, voice(b'GSTR', tg(3, 1, 300),
                                   [(100, b'\x00' * 30), (200, b'\x00' * 30)]), 'gs.dat')
    check('各段取樣加總 300 = 標籤說的 300' in out, '陰性對照:GSTR 容器照大端讀,一樣對得上')

    print('\n五、封裝檔目錄')

    def bigf(items, count=None):
        """拼一個 BIGF:檔頭 16 bytes + 目錄(位移、大小、名稱 + 0x00)+ 資料。"""
        dlen = sum(8 + len(n) + 1 for n, _ in items)
        d, blob = b'', b''
        for n, data in items:
            d += struct.pack('>II', 16 + dlen + len(blob), len(data)) + n + b'\x00'
            blob += data
        return (b'BIGF' + struct.pack('<I', 16 + dlen + len(blob))
                + struct.pack('>II', len(items) if count is None else count, dlen) + d + blob)

    three = bigf([(b'a.dat', good), (b'b.dat', good), (b'c.dat', good)])
    r, _ = quiet(big_entries, three)
    check(isinstance(r, list) and [x[0] for x in r] == ['a.dat', 'b.dat', 'c.dat'],
          '陰性對照:完整的目錄三項都列得出來')
    cut = 16 + (8 + 6) + 8 + 3          # 第二項的名稱切在一半,沒有結尾的 0x00
    r, _ = quiet(big_entries, three[:cut])
    check(isinstance(r, list) and [x[0] for x in r] == ['a.dat'],
          '目錄在名稱中間被截斷時,只列出完整的那一項,不丟例外', True)
    cut = 16 + (8 + 6) + 4              # 第二項的位移與大小那 8 個位元組只剩一半
    r, _ = quiet(big_entries, three[:cut])
    check(isinstance(r, list) and [x[0] for x in r] == ['a.dat'],
          '目錄在位移與大小那一欄被截斷時,一樣只列出完整的那一項', True)
    check(quiet(big_entries, b'BIGX' + three[4:])[0] is None, '開頭不是 BIGF 就回 None', True)

    print('\n六、聲道')
    check(stops(split_channels, b'\x00' * 16, 0), '聲道數 0 要停下來', True)
    r, _ = quiet(split_channels, stereo(groups(1), groups(1)), 2)
    check(isinstance(r, list) and [len(x[1]) for x in r] == [15, 15],
          '陰性對照:兩聲道切成一樣長的兩段(各 15 bytes)')

    print('\n七、掃相位')
    check(find_phase(b'\x21' * 10)[1] < 0, '不到一組(10 bytes)要回「量不出來」', True)
    check(find_phase(groups(1)) == (0, 1.0), '剛好一組(15 bytes)要算得出來,不可以漏掉最後一組', True)
    check(find_phase(groups(4, lead=b'\xf7' * 3)) == (3, 1.0),
          '陰性對照:前面有 3 個位元組前置時,相位掃到第 3 格、100%')
    tiny = parse_voice(voice(b'GSTR', tg(3, 2, 0), [(0, stereo(b'\x21' * 10, b'\x21' * 10))]))
    _, out = quiet(show_blocks, tiny)
    check('量不出來' in out and '-100' not in out,
          '--blocks 遇到不到一組的聲道,印「量不出來」而不是 -100%', True)

    print('\n八、--blocks 只對 GSTR 容器的編碼 3 套擺法')
    _, out = quiet(show_blocks, parse_voice(pt3))
    check('不套上去' in out and '表頭高半位元組' not in out, 'PT 容器的編碼 3 停手,不套擺法', True)
    _, out = quiet(show_blocks, parse_voice(voice(b'GSTR', tg(2, 2, 56),
                                                  [(56, stereo(groups(2), groups(2)))])))
    check('本站只解開了' in out and '表頭高半位元組' not in out, '編碼 2 停手,不套擺法', True)
    _, out = quiet(show_blocks, parse_voice(good))
    check('切法正確' in out and '✅' in out, '陰性對照:GSTR 容器的編碼 3 照套,印「切法正確」')

    print('\n九、48000 Hz 那句推論')
    _, out = quiet(describe, pt3, 'pt3.dat')
    check('48000' not in out and '不換算秒數' in out, 'PT 容器的編碼 3 沒標取樣率,不替它換算秒數', True)
    _, out = quiet(describe, voice(b'GSTR', tg(3, 1, 48000), [(48000, b'\x00' * 30)]), 'g.dat')
    check('若是 48000 Hz 則為 1.00 秒' in out, '陰性對照:GSTR 容器的編碼 3 才印那句推論')

    print('\n十、區塊鏈之後的尾巴')
    _, out = quiet(describe, good + good, 'two.dat')
    check('後面還沒讀的語音' in out, '尾巴裡還有一段 SCHl,要說「後面還有語音」,不可以說成補齊', True)
    _, out = quiet(describe, good + b'\x00' * 16, 'pad2.dat')
    check('補齊' in out and '後面還沒讀的語音' not in out, '陰性對照:尾巴只有 0,才說是補齊')

    # 下面兩道要「開檔」,所以暫時換上替身:os.walk 只回報下面這張表裡的名字,
    # 這支腳本用的 open 只從這張表拿位元組。finally 一定換回去。
    # 2026-09-26 加:os.path.isdir 也換成替身,只有 folders 裡的名字算資料夾 ——
    # main() 在開檔之前會先問它,不換的話就只能靠「剛好沒有同名的真資料夾」。
    table = {}
    folders = set()

    def fake_walk(top, *a, **k):
        yield top, [], sorted(table)

    def fake_isdir(path):
        return os.path.basename(path) in folders

    def fake_open(path, mode='r', *a, **k):
        name = os.path.basename(path)
        if isinstance(table.get(name), BaseException):
            raise table[name]
        if name not in table:
            raise FileNotFoundError(2, '(自我測試的替身)沒有這個檔', path)
        return io.BytesIO(table[name])

    def with_fakes(fn, *a):
        g = globals()
        real_walk, real_isdir = os.walk, os.path.isdir
        os.walk, os.path.isdir = fake_walk, fake_isdir
        g['open'] = fake_open
        try:
            return quiet(fn, *a)
        finally:
            os.walk, os.path.isdir = real_walk, real_isdir
            del g['open']

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

    fake_dir = os.path.join('自我測試用的假資料夾', '不會真的去讀')
    broken = b'SCHl' + struct.pack('<I', 4) + b'\x00' * 12
    print('\n十一、--list')
    table.update({'voice.big': bigf([(b'a.dat', good), (b'b.dat', broken), (b'c.txt', b'hello')]),
                  'other.big': b'NOTBIGF' + b'\x00' * 20,
                  'nothing.big': bigf([(b'x.txt', b'hello')]), 'notes.txt': b'x'})
    _, out = with_fakes(cmd_list, fake_dir)
    check('2 個語音' in line_of(out, 'voice.big') and '3×1' in line_of(out, 'voice.big'),
          '讀不懂的那一項跳過,清單照樣列完(2 個語音、讀得懂的 1 個是編碼 3)', True)
    check('other.big' not in out, '不是 BIGF 的 .big 跳過,不列出來', True)
    check('nothing.big' not in out, '是 BIGF 但裡面一段語音都沒有的,也不列出來', True)
    table.clear()
    table.update({'voice.big': bigf([(b'a.dat', good), (b'b.dat', good)])})
    _, out = with_fakes(cmd_list, fake_dir)
    check('3×2' in line_of(out, 'voice.big'), '陰性對照:兩項都讀得懂時兩項都算進去(3×2)')
    table.clear()
    table.update({'notes.txt': b'x'})
    _, out = with_fakes(cmd_list, fake_dir)
    check('沒有找到語音封裝檔' in out, '陰性對照:一個封裝檔都沒有時,說找不到')
    # 2026-09-26 加:整個 .big 讀不到。走 main() 那條路,連「--list 不可以被下面
    # 單一檔案那幾句話接走」一起驗 —— 09-26 第一版就是被那幾句接走,
    # 印成「沒有權限讀這個檔:<你給的資料夾>」,清單也停在半路。
    # 斷掉的連結在 os.walk 眼裡也是一個檔,open 它丟的是 FileNotFoundError,一起放進來。
    table.clear()
    table.update({'voice.big': bigf([(b'a.dat', good), (b'b.dat', good)]),
                  'locked.big': PermissionError(13, '(自我測試的替身)Permission denied'),
                  'gone.big': FileNotFoundError(2, '(自我測試的替身)斷掉的連結')})
    rc, out = run_main(fake_dir, '--list')
    check(rc == 0 and '3×2' in line_of(out, 'voice.big')
          and '讀不到' in line_of(out, 'locked.big') and '讀不到' in line_of(out, 'gone.big')
          and '有 2 個 .big 讀不到' in out
          and '沒有權限讀這個檔' not in out and '找不到檔案' not in out,
          '有 .big 讀不到(沒有權限、斷掉的連結)→ 點名跳過、其餘照樣列完、回 0,'
          '不把資料夾說成那個檔', True)
    table.clear()
    table.update({'locked.big': PermissionError(13, '(自我測試的替身)Permission denied')})
    rc, out = run_main(fake_dir, '--list')
    check(rc == 0 and '讀不到' in line_of(out, 'locked.big') and '沒有找到語音封裝檔' not in out,
          '唯一的 .big 讀不到 → 說讀不到,不說成「沒有找到語音封裝檔、請確認資料夾」', True)

    print('\n十二、命令列')
    table.clear()
    # 資料夾要兩個替身一起下(2026-09-26 改):folders 讓 isdir 說「是資料夾」,
    # 表裡放的是真的去 open 時會丟的例外 —— Mac / Linux 是 IsADirectoryError,
    # Windows 是 PermissionError(後者是推論,本站還沒在 Windows 上實測)。
    # 「鎖住的檔」不在 folders 裡:isdir 說不是資料夾,open 卻丟 PermissionError。
    folders.update({'Mac的資料夾', 'Windows的資料夾'})
    table.update({'good.dat': good, 'bad.dat': b'RIFF' + b'\x00' * 20,
                  'Mac的資料夾': IsADirectoryError(21, '(自我測試的替身)這是資料夾'),
                  'Windows的資料夾': PermissionError(13, '(自我測試的替身)Permission denied'),
                  '鎖住的檔.dat': PermissionError(13, '(自我測試的替身)Permission denied')})
    rc, out = run_main('good.dat')
    check(rc == 0 and '✅' in line_of(out, '標籤解析到'), '陰性對照:讀得懂的檔回 0')
    rc, out = run_main('bad.dat')
    check(rc == 2 and '停下來了' in out and 'Traceback' not in out,
          '讀不懂的檔印一句話、回 2、不噴 traceback', True)
    rc, out = run_main('沒有這個檔.dat')
    check(rc == 2 and '找不到檔案' in out, '找不到檔案時印一句話、回 2', True)
    said = '這是一個資料夾,不是單一語音檔'
    for name, how in (('Mac的資料夾', 'Mac / Linux 的 open 丟 IsADirectoryError'),
                      ('Windows的資料夾', 'Windows 的 open 丟 PermissionError')):
        rc, out = run_main(name)
        check(rc == 2 and said in out and '--list' in out and 'Traceback' not in out,
              '給了資料夾卻沒加 --list(%s)→ 同一句話、提示加 --list、回 2' % how, True)
    rc, out = run_main('鎖住的檔.dat')
    check(rc == 2 and '沒有權限讀這個檔' in out and said not in out and 'Traceback' not in out,
          '不是資料夾、open 卻丟 PermissionError → 說沒有權限讀、不說成資料夾、回 2', True)
    table.clear()
    folders.clear()

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

    print()
    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:]:
        # 跟 main() 開頭那一段同一個理由:輸出導到檔案或管線時,cp950 編不出 ✅ ❌,
        # 不處理就會在第二行丟 UnicodeEncodeError。一樣只把編不出來的字換成問號。
        try:
            sys.stdout.reconfigure(errors='replace')
        except (AttributeError, ValueError):
            pass
        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.
# ─────────────────────────────────────────────────────────