教學 › 換掉遊戲的聲音
換掉遊戲的聲音
本站卡最久的一格解開了。EA 的聲音格式現在解得開也寫得回去, 用一支純 Python、零相依的腳本 —— 不需要任何二十年前流出的工具。
會改你的檔案
會動到遊戲檔的只有 --import … --apply 與 --restore 兩條路。
--restore 不吃 --apply,打下去就會把遊戲檔換成備份那一份(本站實測)——
換的方式是先寫暫存檔、比對過才換名,不是直接蓋。第一次寫入前會自動備份。
驗到什麼程度
| 驗什麼 | 結果 |
|---|---|
解碼,對照 ffmpeg 的輸出 |
21,678,328 個取樣,0 個不同(一整首四分鐘立體聲) |
| 編碼寫回去再讀出來 | 98.1 dB,66,286/66,640 逐取樣完全相同 |
| 預覽不動檔案 | ✅ md5 前後相同 |
| 還原 | ✅ SHA-256 回到原檔 |
| 對齊守門 | ✅ 正確拒絕了會錯位的那一段 |
⚠️ 2026-09-05 訂正:上面那個 98.1 dB 是
修好立體聲起始狀態之前量的,整組數字本站還沒有重新量過。
修好之後立體聲那一半會更好:本站拿剛安裝好的原版 menu9.asf 切出 8 個區塊
(31,360 個取樣)做「匯出 → 匯入 → 再用 ffmpeg 當獨立解碼器讀回來」,
0 個取樣不同。單聲道與 GSTR 那一半不受這個修正影響。
🛟 動手之前:先完整備份整個遊戲資料夾
把整個遊戲資料夾複製一份到別的地方, 包括裡面存放紀錄的資料夾。不要挑檔案,整包複製最省事也最保險。
「紀錄」是指這些(它們跟遊戲檔混在同一個資料夾裡):
Support\MVP Baseball 2005\—— 遊戲自己建的, 裡面是設定(MVPOptions_Options.sav)與手把設定 (controller.cfg)。剛安裝好時沒有這個資料夾,玩過才出現。- 王朝、老闆模式、名單與玩家檔案的存檔(
*_Franchise.sav、*_Owner Mode.sav、*_Rosters.sav、*_Profile.sav)—— 沒有固定的資料夾名,你或你裝的模組包叫它什麼它就叫什麼。找不到就用這一行找:
dir /s /b "〔你的遊戲資料夾〕\*.sav"
find "〔你的遊戲資料夾〕" -name "*.sav"
上面第一行 Windows、第二行 Mac。 本站每一支工具都會自己備份它要動的那一個檔, 但那只保得住那一個檔 —— 整包備份保的是「你玩到現在的一切」。 完整做法看備份與還原 SOP。
⚡ 只想趕快做完?照這五步
- 下載 mvp_audio.py,先看這個檔是什麼,它會告訴你編碼器、聲道、取樣率、裡面有幾段
python3 mvp_audio.py "〔聲音檔〕" --info - 列出裡面每一段,一個檔裡常常串著上萬段
python3 mvp_audio.py "〔聲音檔〕" --list - 把要換的那一段匯出成 WAV,先聽聽看是不是那一句
python3 mvp_audio.py "〔聲音檔〕" --export 7 out.wav - 做你自己的錄音,規格要跟匯出的那個一樣:聲道數一樣、16 位元的 WAV、取樣率照匯出的那個(長度可以不一樣,比較短會補靜音,比較長會被切掉)。聲道數與位元深度不一樣工具一定擋,取樣率不一定:檔頭沒寫取樣率的段那一關會跳過,見下面那張卡
- 先預覽,確認之後同一行加
--apply才真的寫入,第一次寫入前會自動備份,換完不對就跑--restore
python3 mvp_audio.py "〔聲音檔〕" --import 7 mine.wav
後悔的話,這一行還原:
python3 mvp_audio.py "〔聲音檔〕" --restore
想先確認這支腳本本身沒壞,跑一次自我測試(不需要遊戲檔,全程只在系統暫存資料夾裡做):
python3 mvp_audio.py --selftest
Windows 把 python3 換成 python。想懂公式怎麼解出來的往下讀;只想複習跳到📌 重點整理。
⭐ 為什麼這一格卡了那麼久
本站早就量出容器結構:一組 15 個位元組(1 表頭 + 14 資料)對應 28 個取樣, 兩個聲道各自連續不是交錯。缺的只是「那 15 個位元組怎麼變回 28 個數字」。
之前試過一千種以上的參數組合:位移基準、係數表、半位元組順序、 濾波器寫法、前置長度,全部排列組合跑過。最好的結果只有 0.16 的相關。
解開它的不是更聰明的組合,是一份標準答案
那一千次嘗試有一個共同的致命傷:沒有東西能告訴你「你錯了」。 解出來的波形好不好,只能用感覺判斷,而感覺不會收斂。
轉折點是發現:免費的 ffmpeg 解得開遊戲的選單音樂。
那就是一份可以逐取樣比對的對照組。有了它,把參數空間掃一遍,
1,024 種組合裡只有一組給出 100.00%,第二名 96.90%,
第三名掉到 18.91% —— 不是勉強領先,是斷崖。
沒有標準答案的時候,一千次嘗試不會收斂,只會累積。
還有第二個原因:這個遊戲有三種編碼器
聲音檔的檔頭是「標籤、長度、值」的串列,其中標籤 0x80 是編碼器編號:
0x80 | 檔數 | 是什麼 | 差別 |
|---|---|---|---|
| 1 | 9 | 選單音樂(立體聲;剛安裝好的原版是 29400 Hz,本站測試機那份被社群模組換成 44100 Hz) | 立體聲,所以每個區塊開頭 4 bytes 是這一塊的起始狀態 |
| 2 | 24 | 語音與音效(多為單聲道) | 單聲道的段狀態跨區塊延續,那 4 bytes 不是種子 |
| 3 | 0 | 社群語音包用的那種,大多裝在 GSTR 容器裡(本站測試機上另有 589 段裝在 PT 容器裡)。剛安裝好的原版 data\audio 裡一個都沒有 |
取樣的算法跟 2 一樣,差在容器裡的數字用哪一種位元組序 —— 那是看容器標記不是看編碼器編號(GSTR 大端、PT 小端)。2026-08-30 修好了,解得開,但不是每一段都解得出來 |
取樣的數學三種完全一樣,差的是狀態怎麼傳遞,以及容器裡的數字用哪一種位元組序。
⚠️ 2026-09-05 訂正:起始狀態怎麼傳遞看的是聲道數,不是編碼器編號 ——
立體聲每一塊開頭 4 個位元組是那一塊自己的起始狀態,單聲道則是狀態跨區塊延續。
剛安裝好的原版那份 data\audio 裡就有 296 段編碼器 2 是立體聲
(本站重量過,英文版與中文版數字一樣),所以不能照編碼器編號分。
同樣地,編碼器 3 不等於 GSTR 容器:本站測試機那份 data\audio 的
8,909 段編碼器 3 裡,8,320 段裝在 GSTR、589 段裝在 PT,
位元組序要看容器標記決定(見下面那張卡)。
⚠️ 上表的「檔數」是一個檔算一個,看它第一段的編碼器,量的是剛安裝好的原版那份 data\audio。
本站對英文版與中文版兩份剛安裝好的安裝各跑了一次,兩份都是 9 / 24 / 0。
也就是說編碼器 3 在原版的 data\audio 裡一個都沒有:整份安裝裡唯一用到它的是
data\frontend\movies\ 底下的影片,那裡 16 個 .vp6 有 15 個帶 SCHl 音軌,
這 15 個全是編碼器 3(creditm.vp6 裡沒有 SCHl)。
本站測試機那份 data\audio 裝過社群模組,同樣算法量出來是 9 / 21 / 2:
多出來的兩個編碼器 3 是被模組換掉的 cd\spch_pbp\pnamedat.big 與 cd\hmrn_sfx\hsfxdat.big,
編碼器 2 少掉的第三個是測試機上沒有的 cd\aems\batdit.ast。
順帶一提,測試機那幾個 .big 裡面混著好幾種編碼器(5 個檔是這樣),
所以「一個檔一種」只在剛安裝好的原版上成立。
⭐ 為什麼確定 0x80 是編碼器:在剛安裝好的原版那份 data\audio 裡,
值為 1 的檔剛好就是
menu1 到 menu9 九個選單音樂,一個不多一個不少。
這是名字對上名字,不是數量湊巧。
(2026-09-05 補條件:本站重量過,英文版與中文版各 31,034 段裡都只有 9 段編碼器 1;
裝過社群模組的測試機那份有 464 段,多出來的是
pnamedat.big 裡球員名字的播報語音。所以這句話講的是剛安裝好的原版。)
而以前那一千次嘗試,是拿編碼器 1 的參數去套編碼器 2 的資料——
怎麼調都不會對。
難度
★★☆ 要打指令
時間
約 20 分鐘
要會什麼
會開命令列
公式
寫出來只有幾行。每 15 個位元組一組:
係數表 = [0, 240, 460, 392, 0, 0, -208, -220]
表頭 h:
c1 = 係數表[h >> 4] 高半位元組挑係數(0-3)
c2 = 係數表[(h >> 4) + 4]
位移 = 20 - (h & 0x0F) 低半位元組越大,位移越小
接著 14 個位元組,每個拆成兩個半位元組(高的先):
s = clip( ((sign4(nib) << 位移) + cur*c1 + prev*c2) >> 8 )
prev, cur = cur, s
⚠️ 位移是 20 減去低半位元組。以前失敗的組合裡有一大類寫成相反方向 (位移隨低半位元組變大而變大),那條路怎麼掃都不可能對。
編碼只是把它反過來窮舉
知道解碼公式就寫得出編碼器:對每 28 個取樣, 把 4 個係數 × 16 個位移共 64 種組合全部試一遍,取誤差平方和最小的那組。
只有 64 種、每種只跑 28 個取樣 —— 全掃比任何取巧都省事,而且結果是這個格式能表達的最好解, 不是「夠好就收」。
👉 你要做的事
先看這個檔是什麼。
Windows:
python mvp_audio.py "〔聲音檔〕" --info
Mac / Linux:
python3 mvp_audio.py "〔聲音檔〕" --info
會告訴你編碼器、聲道、取樣率、裡面有幾段。
列出裡面每一段。一個檔裡常常串著上萬段。
Windows:
python mvp_audio.py "〔聲音檔〕" --list
Mac / Linux:
python3 mvp_audio.py "〔聲音檔〕" --list
把要換的那一段匯出成 WAV,先聽聽看是不是那一句。
Windows:
python mvp_audio.py "〔聲音檔〕" --export 7 out.wav
Mac / Linux:
python3 mvp_audio.py "〔聲音檔〕" --export 7 out.wav
做你自己的錄音,規格要跟匯出的那個一樣。
⚠ 三件事不能變(取樣率那一關不一定擋得住)
- 聲道數要一樣(不一樣工具一定擋)
- 16 位元的 WAV(不是 16 位元,讀檔那一步就過不去)
- 取樣率要一樣(本工具不幫你重取樣)。 但這一關要那一段的檔頭有寫取樣率才會比對, 沒寫的話整關跳過,什麼取樣率都收,所以這一件要你自己顧。
長度可以不一樣:比較短會補靜音,比較長會被切掉。 格子是定長的,不能變大。
在剛安裝好的原版那份 data/audio 上量到:31,034 段裡有
30,824 段檔頭沒寫取樣率(99.3%),其中 220 段
連相位那一關也過得去(英文版與中文版量到的數字一樣)。那 220 段只要聲道數對,
丟 8000 Hz 進去也不會被擋,本站拿 cd/aems/batdit.ast 第 0 段
(雙聲道)實測過。匯出這種段時 WAV 檔頭寫的是預設值 22050,
照著做最保險。
先預覽,再寫入。
Windows:
python mvp_audio.py "〔聲音檔〕" --import 7 mine.wav
Mac / Linux:
python3 mvp_audio.py "〔聲音檔〕" --import 7 mine.wav
確認之後同一行加 --apply。第一次寫入前會自動備份。
❌ 出錯處理
| 症狀 | 怎麼辦 |
|---|---|
| 「這一段解不出取樣」 | 2026-08-30 之前,社群語音包每一個檔都會印這句 (562 個檔 0 個解得出)。那是位元組序讀錯,已修 —— 見下面那張卡。修好之後 562 個裡 553 個解得出。 還遇到的話換一段試 |
| 「有 N 個區塊本工具切不出相位,已跳過(匯出的長度會短一些)」 | 匯出的長度會短一點。這種段本工具會拒絕寫入(見下) |
| 「拒絕寫入」 | 這是保護,不是故障。見下面那張卡 |
| 取樣率不一樣 | 用你的音訊軟體轉成跟匯出的那個一樣,再匯入。 但不會每次都跳這句:檔頭沒寫取樣率的段根本不會比對, 直接就收下去了(上面那張卡) |
| 印了「✅ 寫好了」,可是檔案根本沒變 | 2026-09-04 之前,社群語音包(GSTR 容器)每一個檔都是這樣
(本站測試機上 967 個 GSTR 檔,0 個被改到一個位元組)。已修,
見下面那張卡。真的又遇到的話,它現在會拒絕印成功,
不會讓你以為換好了 |
| 換完聲音怪怪的 | --restore |
⚠️ 為什麼有些段會被拒絕寫入
匯出時如果有區塊切不出相位會被跳過,那會讓 WAV 的第 0 個取樣 其實對應到第 1 個區塊。再寫回去就整段錯位。
本站實測過這個 bug:來回訊噪比 −3.1 dB,聽起來就是壞掉。 所以現在的做法是只要有跳過就拒絕寫入。
對不齊就不寫,比「盡量對」安全 —— 錯位的聲音聽起來像壞掉,而且你無從追查是哪裡出的問題。
⭐ 2026-08-30:社群語音包一直解不開,原因不是編碼器,是位元組序
本頁原本寫「編碼器 3(另一種容器)本站沒有處理」,聽起來像是還有一段格式沒解開。 其實編碼器早就對了 —— 卡住的是讀取樣數與聲道起點的那兩行, 本工具無條件用小端讀。
那對原版的 PT 容器是對的,對社群語音包的
GSTR 容器是錯的。而錯的後果不是報錯:
取樣數算出 22 億,除以 28 之後整段被跳過,最後印一句
「這一段解不出取樣」—— 看起來像格式沒解開。
| 容器 | 位元組序 | 實測 |
|---|---|---|
原版的 PT | 小端 | 26/26 成立 |
社群語音包的 GSTR | 大端 | 100/100 成立,零例外 |
看得最清楚的一個例子:8254c.dat 第一段前四個位元組是
00 00 25 84 ——
大端讀出 9,604 個取樣(÷28 = 343 組,跟 body 長度對得上);
小端讀出 2,217,017,344 個,顯然不是取樣數。
修好前後的差別(陰性對照):手上 562 個 GSTR 檔,
強制用修之前的小端 → 0 個解得出來;
改成依容器判斷 → 553 個解得出來(98.4%)。
解出來的音訊:平均 RMS 5,297、峰值 23,255(滿刻度的 71%)、
削波率 0.0008%、無削波。
但「解得開」不等於「每一段都解得開」。
本站把測試機那份 data\audio 裡每一段編碼器 3 都跑過一次(不是抽樣):
共 8,909 段,8,323 段吐得出 WAV(93.4%),
剩下 586 段照樣印「這一段解不出取樣」。
失敗的不是平均散開,是集中在幾個檔:全壘打音效 cd\hmrn_sfx\hsfxdat.big 的
19 段一段都解不出來、cd\spch_pbp\tnamedat.big 的 122 段只有 7 段解得出、
cd\spch_pbp\stdnmdat.big 的 18 段只有 3 段;佔最大宗的
cd\spch_pbp\pnamedat.big 則是 7,092 段裡解得出 6,674 段。
剛安裝好的原版 data\frontend\movies\ 那 15 個帶音軌的影片檔,也是一段都解不出來。
⚠️ 這件事的教訓是失敗的長相:一個讀錯位元組序的 bug,
在畫面上長得跟「這個格式還沒解開」一模一樣。
本站兩支工具對位元組序的假設本來就互相矛盾
(mvp_read_voice.py 用大端、mvp_audio.py 用小端),
而兩邊都只處理一種 —— 沒有人發現,因為它們從來沒有拿同一個檔互相對過。
⭐ 2026-09-04:同一個 bug 還有一半留在寫回去那一邊
上面那張卡修的是解碼。寫回去那一段沒有跟著改:
cmd_import() 讀每個區塊的取樣數還是無條件用小端。
後果不是報錯,是什麼都沒做卻印成功。小端讀出天文數字, 除以 28 之後大到切不出相位,每一塊都被跳過,檔案一個位元組都沒改, 畫面照樣印「✅ 寫好了」。
把本站測試機上收藏的模組與語音檔整包掃過一遍,開頭是 SCHl 的檔共 1,129 個
(上面那張卡的 562 是當時掃到的範圍,這一次掃得比較寬)。
做法是每一個檔都先把第 0 段匯出成 WAV,再把那個 WAV 原封不動匯回去
(所以聲道數與取樣率一定相符),比對前後的 md5:
| 容器 | 檔數 | 修之前跑 --import … --apply 的結果 |
|---|---|---|
社群語音包的 GSTR |
967 | 0 個檔被改到一個位元組:191 個一路印到 「✅ 寫好了」而 md5 前後相同,772 個在相位守門就被明說拒絕, 另外 4 個連匯出都明說解不出來,沒有進到匯入 |
原版的 PT(取 2 MB 以下當對照組) |
47 | 過得了守門的 4 個都真的改到位元組, 沒有這個病 |
看得最清楚的一個例子:張誌家的 8433a.dat 印出「格子總長
5,591,793,664 取樣」「✅ 寫好了(5,591,793,592 取樣)」,
而寫入前後 md5 都是 268cefc3…,一個位元組都沒動。
修好之後同樣 967 個檔重跑:過得了守門的 191 個裡
189 個真的改到位元組(另外 2 個重編出來的位元組跟原檔一樣),
檔案大小 191 個全部前後相同,--restore 191 個全部回到原檔,
匯出、匯入、再匯出的來回訊噪比中位數 91.3 dB、最低 31.2 dB。
⚠️ 順手多加一道網:一組都沒有寫進去就拒絕印成功。 這個 bug 的長相是「什麼都沒做卻報成功」,那是最難發現的一種, 因為畫面上跟真的換好了長得一模一樣。
本站沒有驗的事
- 換完之後遊戲裡真的會唸出來,本站沒有驗。
驗的是「檔案寫進去了、而且寫的內容正確」。
2026-09-04 訂正:這句話以前只在原版的
PT容器上成立, 社群語音包的GSTR容器根本沒寫進去,見上面那張卡。 - 很多段的檔頭沒寫取樣率,本工具預設 22050
(對照工具在測到的檔上也報 22050)。那是預設值不是量到的。
在剛安裝好的原版那份
data/audio量到:31,034 段裡 30,824 段沒寫(99.3%), 其中單聲道 30,707 段、雙聲道也有 117 段(拿來實測的batdit.ast就是雙聲道),所以這不只是單聲道段的事。 這也正是取樣率那一關會被跳過的原因。 編碼器 3(另一種容器)本站沒有處理。✅ 2026-08-30 已處理 —— 而且問題根本不在編碼器,見上面那張卡。 但不是每一段都解得出來:測試機那份data\audio的編碼器 3 共 8,909 段, 8,323 段吐得出 WAV、586 段還是解不出。- 有些段連對照工具也解不開 —— 那是容器層還有分支,不是編碼器的問題。 本工具遇到會明說解不出來,不會吐出垃圾。 不過「一定會明說」這件事本站自己破過一次:寫回去那一段 曾經什麼都沒做卻印「✅ 寫好了」,見上面那張卡。
- 符號連結那一道守門只擋得住一半。 它擋的是目的檔與備份檔本身是不是符號連結, 沒有擋「父資料夾是符號連結」 —— 擋它會誤傷「把整個遊戲資料夾做成一個連結」這種正常用法。
- Windows 的 junction/reparse point 本站還沒實測。
擋連結用的是 Python 的
os.path.islink, 它對 Windows 的 junction 回報什麼,本站還沒在 Windows 上跑過。 - 98.1 dB 是把「本來就是這個格式的聲音」再編一次的數字。 拿全新錄音進來大約 35 dB,那是 4 位元編碼的先天品質, 原廠自己編也是那個數字。 2026-09-05 訂正:這一組數字是修好立體聲起始狀態之前量的, 本站還沒有重新量過整組,見頁首「驗到什麼程度」那張卡的訂正。
📌 重點整理
- 一句話 本站卡最久的一格解開了,EA 的聲音格式現在解得開也寫得回去,用一支純 Python、零相依的腳本,不需要任何二十年前流出的工具。
- 卡住的是沒有標準答案 過去一千種以上的參數組合最好只有 0.16 的相關,直到發現免費的
ffmpeg解得開遊戲的選單音樂,1,024 種組合掃下來只有一組給出 100.00%,第二名 96.90%,第三名掉到 18.91%。 - 驗到什麼程度 解碼對照
ffmpeg的 21,678,328 個取樣 0 個不同,編碼寫回去再讀出來 98.1 dB、66,286/66,640 逐取樣完全相同,預覽 md5 前後相同,還原 SHA-256 回到原檔。2026-09-05 訂正:98.1 dB 是修好立體聲起始狀態之前量的、整組還沒重量;修好之後拿剛安裝好的原版menu9.asf切出的 8 個區塊(31,360 個取樣)走一趟匯出、匯入、再用ffmpeg讀回來,0 個取樣不同。 - 三種編碼器 檔頭標籤
0x80是編碼器編號:1 是選單音樂(剛安裝好的原版是 29400 Hz,測試機那份被社群模組換成 44100 Hz)、2 是語音與音效、3 是社群語音包用的那種(大多裝在GSTR容器裡,本站測試機上另有 589 段裝在PT容器裡;2026-08-30 起解得開,但不是每一段都解得開)。三種都走同一段解碼程式,差的是狀態怎麼傳遞(看聲道數不是看編碼器編號:立體聲每一塊開頭 4 bytes 是那一塊自己的起始狀態,單聲道跨區塊延續;剛安裝好的原版就有 296 段編碼器 2 是立體聲)、以及容器裡的數字用哪一種位元組序(看容器標記:GSTR大端、PT小端)。檔數量在剛安裝好的原版那份data\audio上是 9 / 24 / 0,編碼器 3 在原版只出現在data\frontend\movies\的影片裡;測試機那份裝過社群模組,量出來是 9 / 21 / 2。 - 公式 每 15 個位元組(1 表頭加 14 資料)對應 28 個取樣、兩個聲道各自連續不是交錯,係數表是
[0, 240, 460, 392, 0, 0, -208, -220],位移是 20 減去表頭的低半位元組(寫成相反方向那一大類怎麼掃都不可能對);編碼就是反過來,4 個係數乘 16 個位移共 64 種組合全掃一遍取誤差最小的。 - 換聲音時三件不能變 聲道數要一樣、16 位元的 WAV、取樣率要一樣(本工具不幫你重取樣),長度可以不一樣,比較短會補靜音、比較長會被切掉,因為格子是定長的。但工具一定擋得住的只有前兩件:取樣率那一關要那一段的檔頭有寫取樣率才會比對,剛安裝好的原版那份
data/audio量到 31,034 段裡 30,824 段沒寫(99.3%),這些段只要聲道數對就照收,取樣率得自己顧。 - 最容易踩的雷與安全網 匯出時若有區塊切不出相位會被跳過,那會讓 WAV 的第 0 個取樣其實對應到第 1 個區塊,再寫回去就整段錯位(本站實測來回訊噪比 −3.1 dB),所以「拒絕寫入」是保護不是故障;會動到遊戲檔的只有
--import … --apply與--restore兩條路,--restore不吃--apply(本站實測),第一次寫入前自動備份。備份、寫回去、還原三條路都是原子的:先在同一個資料夾開一個名字猜不到的暫存檔、fsync到磁碟、比對過才換名,正本與備份本身是符號連結就直接拒絕;換完再把檔案讀回磁碟逐位元組複驗,對不起來自動用備份換回去並以非 0 的離開碼收場,中途按 Ctrl+C 一律是 130 而且會明說遊戲檔換掉了沒有。想確認腳本本身沒壞可以跑--selftest,它不碰任何遊戲檔。 - 什麼都沒做卻印成功 2026-09-04 之前寫回去那一段還是無條件用小端讀取樣數,社群語音包的
GSTR容器因此每一塊都被跳過,本站測試機上 967 個GSTR檔 0 個被改到一個位元組,畫面卻照樣印「✅ 寫好了」;修好之後過得了守門的 191 個裡 189 個真的寫進去、檔案大小全部不變、--restore全部回得到原檔,並且加了一道網:一組都沒寫進去就拒絕印成功。 - 本站沒驗的 換完之後遊戲裡真的會不會唸出來沒有驗,驗的是「檔案寫進去了、而且寫的內容正確」;另外檔頭沒寫取樣率的那些段(剛安裝好的原版量到單聲道 30,707 段、雙聲道 117 段)匯出時寫的 22050 是預設值不是量到的、編碼器 3 現在解得開但不是每一段都解得開(測試機那份
data\audio的 8,909 段裡還有 586 段解不出)、有些段連對照工具也解不開,而 98.1 dB 是把本來就是這個格式的聲音再編一次的數字,全新錄音大約 35 dB;擋符號連結那一道只擋得住目的檔與備份檔本身,沒有擋「父資料夾是符號連結」(擋它會誤傷把整個遊戲資料夾做成連結這種正常用法),而 Windows 的 junction/reparse point 本站還沒實測。
這支腳本在做什麼
沒讀過程式碼也看得懂的版本:遊戲的聲音不是 WAV,是 EA 自己的 4 位元壓縮格式,
一段一段裝在容器裡。腳本先把檔案裡的每一段找出來(一路找 SCHl),
再讀那一段的檔頭問出編碼器、聲道、取樣率,
並從容器標記決定裡面的數字要用哪一種位元組序。
接著找出資料真正從第幾個位元組開始,
才開始一組 15 個位元組、一組 15 個位元組地解成取樣,最後寫成一般的 WAV。
匯入是整個反過來走:把你的 WAV 拆成取樣,每 28 個一組窮舉出最接近的 15 個位元組,
填回原本那些格子裡,檔案長度與容器結構完全不動。
| 哪一段 | 做什麼 | 為什麼要有它 |
|---|---|---|
main() |
認參數,決定要做哪一件事。動作互斥,判斷的順序就是優先權:
--restore → --list → --export →
--import,什麼都不給就印檔頭。 |
會動到遊戲檔的只有 --import … --apply 與
--restore 兩條路,其餘全程唯讀。 |
segments()、walk() |
一路找 SCHl 找出檔裡的每一段,再走完每一段的區塊接龍
(SCHl → SCCl → 一堆 SCDl → SCEl)。 |
一個檔常常串著上萬段。遇到不認得的標籤就停, 寧可少走,也不要把後面的東西當成聲音資料解。 |
parse_header() |
讀一段的檔頭:編碼器(標籤 0x80)、聲道、取樣率、取樣總數,
並從容器標記決定位元組序。 |
PT 容器是小端、GSTR 是大端。
2026-08-30 之前無條件用小端,社群語音包 562 個檔 0 個解得出來,
而畫面上看起來像「這個格式還沒解開」。 |
_phase_of() |
找出一塊資料真正從第幾個位元組開始。 | 判準是「每一組表頭的高半位元組都要落在 0 到 3」。 切錯位置解出來就是雜訊,而切錯的時候這條性質幾乎一定會露餡。 |
decode_group() |
15 個位元組換成 28 個取樣:表頭挑係數與位移, 後面 14 個位元組每個拆成兩個半位元組(高的先)。 | 這就是本站卡最久的那一格。位移是 20 減去低半位元組, 以前失敗的組合裡有一大類寫成相反方向,那條路怎麼掃都不可能對。 |
decode_segment() |
把一整段解成「每個聲道一串取樣」,順便處理每一塊的起始狀態。 | 立體聲的兩個聲道在檔案裡是各自連續的兩大塊, 不是左右交錯;而立體聲每一塊開頭 4 個位元組是這一塊自己的起始狀態, 單聲道則是狀態跨區塊延續。 |
write_wav()、read_wav() |
用 Python 內建的 wave 模組讀寫 16 位元 WAV。 |
零相依,不必安裝任何東西。WAV 的聲道是交錯的、 遊戲格式是各自連續,所以兩邊要互相穿插與拆開。只吃 16 位元,其他一律拒絕。 |
encode_group() |
28 個取樣換回 15 個位元組:4 個係數乘 16 個位移共 64 種組合 全部試一遍,取誤差平方和最小的那一組。 | 只有 64 種、每種只跑 28 個取樣,全掃比任何取巧都省事, 而且結果是這個格式表達得出來的最好解,不是「夠好就收」。 |
cmd_export() |
解一段成 WAV,完全不動遊戲檔;輸出檔名要是指到一個 已經存在而且不是 WAV 的檔(手滑打成遊戲檔本身就是這種),會直接拒絕,不會蓋掉它。 | 有區塊切不出相位會被跳過,而且會明說。 那句話不是廢話,它正是下一步拒絕寫入的依據。 |
cmd_import() |
檢查聲道數、取樣率、相位對齊,三關都過才准寫;
寫的時候只填原本那些格子。讀取樣數用的位元組序
跟解碼那邊是同一個(PT 小端、GSTR 大端),
而且一組都沒寫進去就拒絕印成功。 |
匯出時跳過區塊會讓取樣整段錯位(本站實測來回訊噪比 −3.1 dB), 所以對不齊就不寫。格子是定長的,你的錄音只會被補靜音或被切掉,檔案大小前後一樣。 |
_atomic_copy() |
第一次寫入之前把原檔備份成 .audiobak,
先寫成一個名字猜不到的暫存檔(tempfile.mkstemp),
fsync 到磁碟之後才換名。 |
中斷只會留下那個暫存檔,不會留下半截的備份。
2026-09-05 訂正:這一格以前寫「暫存檔叫 .part」「寫入本身是直接覆寫」,
兩句現在都不對了 —— 暫存檔改成猜不到的名字(固定名字會被人先放一個符號連結佔位),
寫回去也走同一套原子換名。備份與寫入是互補的兩道:.audiobak 保住舊內容,
原子換名保住「正本要嘛是舊的、要嘛是新的,不會是半截」。
另外正本與備份只要本身是符號連結就直接拒絕,不管它指到哪裡 ——
寫到連結上,被動到的是它指到的那個檔(有可能在遊戲資料夾外面)。 |
_restore_from_backup()、cmd_restore() |
還原之前用格式驗那份備份完不完整:每一段檔頭的
0x85 寫著這一段有幾個取樣,而那一段每一塊 SCDl
的取樣數加起來必須等於它 —— 尾巴被切掉就一定對不上。
還原本身也是先寫暫存檔、抄好正本的權限、逐位元組比對過才換名,
絕不直接蓋正本;中間任何一步失敗就只把暫存檔刪掉,正本原封不動。 |
半截的備份會把遊戲檔吃掉,而畫面還印成功。
本站 2026-08-29 實測過這件事;2026-09-05 再測一次:把備份截成 60% 再跑
--restore,它明說「這份備份是壞的」、離開碼 1,
而遊戲檔的 md5 一個位元組都沒變。 |
_verify_written() |
換名成功之後把檔案讀回磁碟, 跟「本來要寫的那一份」逐位元組比對。對不起來就自動用備份把它換回去 (換回去也是原子的),然後以非 0 的離開碼收場並印出還原指令。 長度一樣的時候會指出第幾個位元組開始不同。 | 換名成功不等於磁碟上的內容就是你要的 —— 磁碟滿了、外接碟中途掉線、防毒軟體攔截改寫,都做得出這種事。 複驗讀的是磁碟,不是記憶體裡那一份。不會只印一句 ❌ 卻回報成功。 |
會動到你遊戲檔的只有 --import … --apply 與 --restore 兩條路,
而且兩條路之前都先做完備份。它不會幫你重取樣、不會改長度;
至於「換完之後遊戲裡真的會不會唸出來」,本站沒有驗,
驗的是「檔案寫進去了,而且寫的內容正確」。
這一句在 2026-09-04 之前只對原版的 PT 容器成立,
社群語音包的 GSTR 容器當時一個位元組都沒寫進去,
見那張卡。
結束碼:一切正常是 0;備份是壞的、或是寫完複驗對不起來,都是非 0;
中途按 Ctrl+C 一律是 130,
而且會明說遊戲檔換掉了沒有(看的是換名有沒有真的做過,不是用猜的)。
完整原始碼
這支會改你的檔案。會動你檔案的東西,你要能在跑之前先讀完它。
展開 / 收合完整原始碼(1654 行)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mvp_audio.py —— 把遊戲的聲音解成 WAV,也把 WAV 編回遊戲的格式
看檔頭 python3 mvp_audio.py "<聲音檔>" --info
列出裡面的段 python3 mvp_audio.py "<聲音檔>" --list
匯出成 WAV python3 mvp_audio.py "<聲音檔>" --export 0 out.wav
把 WAV 編回去 python3 mvp_audio.py "<聲音檔>" --import 0 in.wav
真的寫入 (上面那行加 --apply)
還原 python3 mvp_audio.py "<聲音檔>" --restore
自我測試 python3 mvp_audio.py --selftest
會動到遊戲檔的只有兩條路:「--import --apply」與「--restore」。
⚠️ --restore 不吃 --apply,備份通過檢查就把 .audiobak 換回遊戲檔
(換的方式是原子的:先寫暫存檔、逐位元組比對過才換名,絕不直接蓋正本)
(本站實測:把正本改壞一個位元組再跑 --restore,md5 回到備份那一份)。
--export 沒加 --apply 也會寫檔,但寫的是你指定的那個 WAV,遊戲檔一個位元組都不動。
╔══════════════════════════════════════════════════════════════════╗
║ 這支怎麼來的 ║
║ ║
║ EA 的聲音格式本站解了很久,一直卡在「一組 15 個位元組怎麼變回 ║
║ 28 個取樣」。過去試過一千種以上的參數組合,最好只有 0.16 的相關。 ║
║ ║
║ 2026-08-29 解開了,而關鍵不是想到更聰明的組合,是**找到標準答案**: ║
║ 免費的 ffmpeg 解得開遊戲的選單音樂,那就是一份可以逐取樣比對的 ║
║ 對照組。有了它,把參數空間掃一遍就找到唯一 100% 吻合的那一組。 ║
║ ║
║ 沒有標準答案的時候,一千次嘗試不會收斂,只會累積。 ║
╚══════════════════════════════════════════════════════════════════╝
── 它到底在做什麼 ────────────────────────────────────────
遊戲的聲音不是 WAV,是 EA 自己的 4 位元壓縮格式,裝在
SCHl → SCCl → 一連串 SCDl → SCEl 這種「一段接一段」的容器裡。
本工具把那個格式解成一般的 16 位元 WAV,也能把你的 WAV 編回去,
而且是**原地填格子**,不重新打包整個檔。
吃什麼(輸入)
· 命令列第一個參數:遊戲裡的一個聲音檔
· --import 時再加一個 **16 位元的 WAV**。聲道數必須跟目標那一段一模一樣,
不一樣直接拒絕;位元深度不是 16 也直接拒絕。
· **取樣率那一關,只在那一段的檔頭有寫取樣率時才會擋。** 檔頭沒寫的段這一關
整個跳過,什麼取樣率都收得下去,因為沒有數字可以拿來比。本站在剛安裝好的
原版那份 data/audio 上量到:31,034 段裡有 30,824 段檔頭沒寫取樣率(99.3%),
其中 220 段連相位那一關也過得去(英文版與中文版量到的數字一樣)。那 220 段
只要聲道數對,丟 8000 Hz 進去也不會被擋(實測
data/audio/cd/aems/batdit.ast 第 0 段,雙聲道)。本工具還是不幫你重取樣,
所以取樣率得你自己對:匯出這種段時 WAV 檔頭寫的是預設值 22050 Hz,
照著做最保險,但那是預設值不是量到的。
吐什麼(輸出)
· --info / --list:只印在畫面上
· --export:寫出一個 WAV,遊戲檔一個位元組都不動。輸出檔名要是指到一個
**已經存在而且不是 WAV** 的檔(手滑打成遊戲檔本身就是這種),直接拒絕,
不會蓋掉它;是符號連結也拒絕。真正寫出去的方式跟遊戲檔同一套
(先寫一個猜不到名字的暫存檔,fsync 過才換名),所以不會沿著連結
把資料夾外面的檔寫壞
· --import 沒加 --apply:只印預覽,遊戲檔一個位元組都不動
· --import 加了 --apply:原地覆寫那一段的取樣,並留下 <原檔名>.audiobak
⚠️ 2026-09-04 訂正:這一句以前對社群語音包(GSTR 容器)是假的,它一個位元組
都沒寫,卻照樣印「✅ 寫好了」。原因與量到的範圍見 cmd_import 上面那段。
· --restore:把 .audiobak 蓋回去
安全網
1. 預設唯讀。會動遊戲檔的只有「--import --apply」與「--restore」兩條路。
2. 第一次寫入之前自動備份,而且**備份、寫回去、還原三條路都是原子的**:
先在同一個資料夾裡開一個**名字猜不到**的暫存檔(tempfile.mkstemp),
寫完 fsync 到磁碟,再用 os.replace 換上去。中途被中斷只會留下那個暫存檔,
不會留下半截的遊戲檔。⚠️ 2026-09-05 訂正:以前用的是「<檔名>.part」這個
**猜得到**的名字,有人先在那裡放一個指向資料夾外面的符號連結,
就會沿著它把外面的檔案截成 0 bytes。現在暫存檔一律 mkstemp,
而且**每一個要寫的目的檔**只要是符號連結就直接拒絕:正本、備份、
還原的目標、--export 的輸出都算(2026-09-06 補上輸出那一個)。
路徑中間的資料夾是連結沒關係(有人把遊戲裝在別顆碟),只看最後那個檔。
3. 寫回去之前先試解一次:只要有任何一個區塊切不出相位就**拒絕寫入**。
因為那代表匯出時少了一塊,取樣會整段錯位(本站實測來回訊噪比 -3.1 dB)。
4. --restore 之前用格式驗那份備份完不完整:每一段的檔頭寫著取樣總數,
把那一段每個 SCDl 的取樣數加起來必須等於它 —— 截斷就一定對不上。
還原完之後再逐位元組複驗一次,對不起來就報錯不印成功。
還原本身也是「先寫暫存檔 → 比對過 → os.replace」,任何一步失敗正本原封不動。
5. 格子是定長的:寫回去只填格子,不改長度,所以檔案大小前後一樣。
6. 寫入迴圈**一組都沒寫進去就拒絕印成功**。2026-09-04 之前沒有這道網,
位元組序讀錯的那段期間,社群語音包每一個檔都是「什麼都沒做卻印 ✅」。
7. 寫完之後把檔案**讀回來**跟「本來要寫的那份」逐位元組比對;對不起來就
自動用備份換回去,並以非 0 的離開碼收場(不會只印一句 ❌ 卻回報成功)。
8. 按 Ctrl-C 會明說「遊戲檔換掉了沒有」——看的是 os.replace 有沒有真的做過,
不是用猜的 —— 而且一律以 130 收場,不會是 0。
⚠️ 2026-09-06 訂正:以前是「先舉旗、遇到任何例外就把旗子放下」,
Ctrl-C 剛好落在「換名成功」與「放旗」之間時,旗子會被放下 ——
畫面就會說「一個位元組都沒有動到」而檔案其實已經換了。現在換名與登記
綁成一段不可中斷的動作(見 _NoInterrupt),而且狀態有三種:
沒動過 / 正在換 / 已經換掉。萬一真的停在中間,說的是「正在替換」,
不會說成「沒動到」。
做不到的事(先說在前面)
· **不會改長度。** 你的 WAV 比較短會補靜音,比較長會被切掉。
· **不幫你重取樣、不幫你混音、不幫你轉位元深度。** 聲道數與位元深度對不上
一定拒絕;取樣率對不上只有在那一段的檔頭有寫取樣率時才擋得住
(見上面「吃什麼」那一條)。轉檔請自己在音訊軟體裡做完再進來。
· 編碼是有損的:4 位元格式先天如此。把「本來就是這個格式的聲音」再編一次,
本站量到 98.1 dB;拿全新錄音進來大約 35 dB,原廠自己編也是那個數字。
⚠️ 2026-09-05 訂正:98.1 dB 是**修好立體聲起始狀態之前**量的(見 cmd_import
寫入迴圈裡那段註解)。修好之後立體聲那一半會好很多:拿剛安裝好的原版
menu9.asf 的 40 個區塊實測,修之前 156,800 個取樣有 4,365 個不同(最大誤差
2,129、46.6 dB),修之後 0 個不同、誤差 0 —— 而且是用 ffmpeg 當獨立的
解碼器去讀「寫回去的那個檔」驗的,不是拿本工具自己驗自己。
單聲道與 GSTR 那一半不受影響(新舊寫出來的位元組完全相同)。
98.1 dB 那一整組數字本站還沒有重新量過,先照實說它是舊的。
· 有些段連對照工具也解不開,那是容器層還有分支。本工具遇到會明說解不出來,
不會吐出垃圾給你。⚠️ 但「一定會明說」這件事本站自己破過一次:寫回去那一段
曾經什麼都沒做卻印「✅ 寫好了」(2026-09-04 修,見 cmd_import 上面那段)。
· **換完之後遊戲裡真的會不會唸出來,本站沒驗。** 驗的是「檔案寫進去了,
而且寫的內容正確」。⚠️ 2026-09-04 訂正:這句話以前只在 PT 容器上成立,
GSTR 容器根本沒寫進去,見 cmd_import 上面那段。
"""
#
# ─────────────────────────────────────────────────────────
# 法律與免責(每一支本站腳本都帶著這一段)
#
# · 本工具與 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 sys
import wave
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 只看開頭,看不出後面少了多少。
# 被 Ctrl-C 打斷的時候,讀者只想知道一件事:遊戲檔到底換掉了沒有。
# 這件事有**三種**狀態,不是兩種:
# 沒動過 _LIVE_REPLACED False 而且 _LIVE_REPLACING 是 None
# 正在換 _LIVE_REPLACING 記著那個檔名(換成功了沒有還不確定)
# 已經換掉 _LIVE_REPLACED True(os.replace 真的做完而且登記過了)
# 只有第一種可以跟讀者說「一個位元組都沒有動到」—— 那句話不可以用猜的。
_LIVE_REPLACED = False
_LIVE_REPLACING = None
# ── 換名與登記要綁成一段不可中斷的動作(2026-09-06 第三輪稽核改)──────────
# 舊版是「先舉旗 → os.replace → 任何例外就把旗子放下」。看起來保守,其實會說謊:
# os.replace **已經成功**之後才收到 Ctrl-C,那個 KeyboardInterrupt 一樣會走進
# 「把旗子放下」那一行,於是畫面印「一個位元組都沒有動到」,而檔案其實已經換了。
# 讀者就不會去還原 —— 這正是不可以發生的那個方向。
#
# 現在的做法:換名與登記兩行包在 _NoInterrupt 裡。這段期間收到的 Ctrl-C 只會被
# 「記著」,等兩行都做完才照常丟出去,所以收尾看到的登記一定跟磁碟上的狀態一致。
# 旗子只有在 os.replace **自己失敗**(確定沒換成)的時候才會保持沒舉起來的樣子。
class _NoInterrupt(object):
"""把「os.replace + 登記」包起來:這段期間收到 Ctrl-C 先記著,離開這段之後再照常丟出。
非主執行緒之類裝不上處理器的情況,就退回原本的行為(不會比舊版更糟),
這時候第三態「正在換」就是保險 —— 見 _interrupt_note()。
"""
def __enter__(self):
self._pending = False
self._old = None
try:
self._old = signal.signal(signal.SIGINT, self._remember)
except (ValueError, OSError):
self._old = None
return self
def _remember(self, signum, frame):
self._pending = True
def __exit__(self, exc_type, exc, tb):
if self._old is not None:
try:
signal.signal(signal.SIGINT, self._old)
except (ValueError, OSError):
pass
if self._pending and exc_type is None:
raise KeyboardInterrupt
return False
def _note_live_replaced(value=True):
"""舉起或放下「正本已經被換掉」這面旗子,回傳它原本的值。"""
global _LIVE_REPLACED
was = _LIVE_REPLACED
_LIVE_REPLACED = value
return was
def _note_replacing(target):
"""登記「現在正在換這個檔」(target 給 None 代表這件事已經有結論了)。"""
global _LIVE_REPLACING
_LIVE_REPLACING = os.fspath(target) if target is not None else None
def _replace_live(tmp, dst):
"""把暫存檔換上正本 —— 換名與登記是不可分的一步。
進來之前先登記「正在換 dst」,兩種結局各自把它收掉:
· os.replace 自己丟例外 → 確定沒換成,收掉登記,旗子維持沒舉起來
· 換名成功 → 舉旗、收掉登記,兩件事都在 _NoInterrupt 裡面做完
"""
_note_replacing(dst)
with _NoInterrupt():
try:
os.replace(tmp, dst)
except BaseException:
# 換名自己失敗才走這裡:這時候「一個位元組都沒有動到」是真的。
_note_replacing(None)
raise
_note_live_replaced(True)
_note_replacing(None)
# ── 暫存檔的名字不可以是猜得到的(2026-09-05 第二輪稽核加)──────────────
# 原本備份與寫入都用「<目的檔>.part」這個固定名字。名字固定就代表**別人可以
# 先佔位**:在遊戲資料夾裡先放一個叫 <目的檔>.part 的符號連結指到資料夾外面,
# open(那個名字, 'wb') 會沿著連結,把外面那個檔案當場截成 0 bytes。
# 後面的 os.replace 只換掉連結本身,可是傷害在那之前就已經造成了。
#
# 改用 tempfile.mkstemp:它內部是 O_CREAT|O_EXCL,名字已經被佔走就直接失敗,
# 也不會跟著符號連結走。名字開頭加一個點,是為了萬一真的被中斷留在那裡時,
# 讀者一眼看得出那是誰留下的。
def _mkstemp_beside(dst, kind):
"""在 dst 所在的資料夾裡開一個獨一無二的暫存檔,回傳 (fd, 路徑)。
一定要跟目的檔同一個資料夾:os.replace 只有在同一個檔案系統上才是原子的,
寫到 /tmp 再搬過來就不是了(跨磁碟會退化成複製)。
"""
dst = os.fspath(dst)
d = os.path.dirname(os.path.abspath(dst)) or '.'
return tempfile.mkstemp(dir=d, prefix='.%s.%s-' % (os.path.basename(dst), kind))
# 目的地是符號連結就拒絕,不管它指到哪裡。
# ⚠️ 不可以只用 os.path.exists() 判斷:符號連結指向一個不存在的檔時
# exists() 回 False,看起來像「這個名字是空的」,可是一寫下去就會沿著它
# 在資料夾外面建檔或覆寫。要看「連結自己在不在」得用 os.path.lexists /
# os.path.islink —— 這兩個不跟著連結走。
def _reject_symlink(p, what):
p = os.fspath(p)
if os.path.islink(p):
try:
target = os.readlink(p)
except OSError:
target = '(讀不出來)'
raise DataError(
'%s(%s)是一個符號連結,指向 %s —— 拒絕。\n'
' 本工具只寫真正的檔案:寫到連結上,被動到的是它指到的那個檔\n'
' (有可能在遊戲資料夾外面);換名字的時候又只換掉連結本身,\n'
' 真正那個檔反而留著舊內容。兩種結果都不是你要的。\n'
' 請把路徑直接指向真正的那個檔。' % (what, p, target))
def _atomic_copy(src, dst):
"""備份要嘛完整、要嘛不存在 —— 中間狀態不會留在 dst 這個名字上。
⚠️ 本站有些腳本用 pathlib.Path 存路徑,有些用字串。
2026-08-29 第一版寫成 dst + '.part',在 Path 上直接 TypeError,
等於所有備份都失敗 —— 而且「半截備份被擋下來」那個測試照樣是綠的。
是陰性對照(先證明正常流程真的會產生備份)抓到的。
⚠️ 2026-09-05:那個固定名字連同「猜得到」這件事一起換掉了,改用
_mkstemp_beside(理由見上面那一段)。
"""
src, dst = os.fspath(src), os.fspath(dst)
_reject_symlink(dst, '備份檔')
# 先寫到一個猜不到的暫時名字,寫完、而且真的落到磁碟了,才換成正式名字。
fd, tmp = _mkstemp_beside(dst, 'backup')
try:
with os.fdopen(fd, 'wb') as fout:
with open(src, 'rb') as fin:
shutil.copyfileobj(fin, fout)
fout.flush()
os.fsync(fout.fileno()) # 少了這一步,斷電之後留下的可能是空殼
shutil.copystat(src, tmp) # 權限與修改時間跟著走(等同 copy2)
os.replace(tmp, dst) # os.replace 是原子的
except BaseException:
# 失敗就把半截的暫存檔收掉,再把原本的例外原封不動丟出去。
# 這裡只負責清乾淨,不負責決定「要不要繼續」。
try:
os.remove(tmp)
except OSError:
pass
raise
def _restore_from_backup(bak, dst):
"""還原之前先擋掉明顯壞掉的備份。
⚠️ 這裡**不能**比對「備份與目標大小相同」—— 本站多數腳本是把資料接到
檔尾來改檔(專案鐵律:封裝檔不可重新打包),改完之後正本本來就比備份大,
那樣比會擋掉每一次合法的還原。
── 2026-08-30 補上三道(上線前資安稽核抓到的真漏洞)────────────────
原本只有「不是 0 bytes」+「BIGF 檔頭宣告長度」兩道。**BIGF 以外全破。**
實測拿「前 1/8 的半截備份」去還原,六支腳本把正本吃掉而且都印成功:
mvp_fix_loc / mvp_menu_text .LOC 416,753 → 52,094
mvp_edit_speed / mvp_ratings
/ mvp_player attrib.dat 840,643 → 105,080
mvp_modernize mvp2005.exe 5,443,584 → 680,448
(它還印「複驗:內容與備份相同 ✅」)
最後那個會讓遊戲**完全開不起來**,而站上每一課都寫著「隨時可以 --restore」。
現在檢查五件事:
1. 備份不是 0 bytes
2. BIGF:檔頭第 4-8 個位元組宣告的總長度要等於實際長度
(兩種位元組序都接受;哪些檔是大端、各有幾個,以 reference/bigf.html 量到的為準,這裡不寫會過期的數字)
3. LOCH(語系檔):檔頭指到的 LOCL 要在檔內,而且最後一條字串的位移
也要在檔內 —— 截斷之後那個位移一定會超出去
4. MZ(執行檔):PE 節區表裡 raw offset + raw size 的最大值不得超過檔案長度
5. **通用地板**:非 BIGF 的備份不得小於「要被蓋掉的那個檔」的一半。
非 BIGF 的工具都是原地改(大小幾乎不變),所以這條很安全;
BIGF 走 append 會越改越大,所以刻意**不套**這條,由第 2 道負責。
"""
import struct
bak, dst = os.fspath(bak), os.fspath(dst)
if not os.path.exists(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):
raise SystemExit(
'這份備份是壞的,不敢拿它覆蓋 %s。\n'
' %s\n'
' 多半是備份途中被中斷(磁碟滿、外接碟拔掉、按了 Ctrl-C)。\n'
' 請改用你自己另外留的那一份備份。' % (dst, why))
# 第 2 道:BIGF 封裝檔。檔頭第 4-8 個位元組寫著「整個檔應該多長」,
# 跟實際長度對不上就是被截斷了。小端與大端兩種都接受:
# 本機 295 個 BIGF 檔裡 288 個小端、7 個大端,兩種都是真的。
if len(head) == 8 and head[:4] == b'BIGF':
le = struct.unpack('<I', head[4:8])[0]
be = struct.unpack('>I', head[4:8])[0]
if le != n and be != n:
_stop('檔頭說它應該是 %d bytes(或 %d),實際只有 %d bytes。' % (le, be, n))
return _do_copy(bak, dst)
# 第 3 道:語系檔。位移 16-20 指向字串區 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 執行檔。位移 0x3C 是 PE 檔頭的位置,PE 檔頭後面是節區表,
# 每個節區 40 bytes,其中位移 16-24 是「這一節在檔案裡的長度與起點」。
# 所有節區的結尾取最大值,那就是這個執行檔至少該有多長。
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)
# 上面那幾道都沒喊停才會走到這裡:整支腳本真正「把備份換回正本」的動作在這裡。
#
# ── 為什麼不是一行 shutil.copy2(bak, dst)(2026-09-05 第二輪稽核改)────────
# copy2 的第一個動作就是把目的檔開成 'wb' —— **那一行下去正本當場變 0 bytes**,
# 之後才慢慢把備份的內容寫回去。中途按 Ctrl-C、磁碟滿、外接碟被拔掉,
# 留下來的就是一個半截的遊戲檔。而讀者會跑 --restore,正是因為正本已經有問題;
# 這一刀等於連他最後的退路也切斷。本工具處理的檔可以到 24 MB
# (data/audio/cd/aems/crowd4a.ast),那個窗口不是一瞬間。
#
# 現在的順序是:在正本那個資料夾裡開一個猜不到名字的暫存檔 → 寫進去 → fsync →
# 抄正本的權限 → **讀回來跟備份逐位元組比對** → 才 os.replace 換上去。
# 任何一步失敗都只丟掉暫存檔,正本一個位元組都不會被動到。
def _do_copy(bak, dst):
bak, dst = os.fspath(bak), os.fspath(dst)
_reject_symlink(bak, '備份檔')
_reject_symlink(dst, '要還原的那個檔')
fd, tmp = _mkstemp_beside(dst, 'restore')
try:
with os.fdopen(fd, 'wb') as fout:
with open(bak, 'rb') as fin:
shutil.copyfileobj(fin, fout)
fout.flush()
os.fsync(fout.fileno())
# 權限要抄**正本**的,不是備份的:換名之後權限跟著新檔走。
if os.path.exists(dst):
try:
shutil.copymode(dst, tmp)
except OSError:
pass
# 換上去之前,先確認「已經落到磁碟上的那一份」真的等於備份。
# 比的是完整長度加完整內容,不是抽樣。
if os.path.getsize(tmp) != os.path.getsize(bak):
raise DataError('還原用的暫存檔跟備份不一樣長,不敢換上去'
'(%s 一個位元組都沒有被動到)' % dst)
with open(bak, 'rb') as f1, open(tmp, 'rb') as f2:
if f1.read() != f2.read():
raise DataError('還原用的暫存檔跟備份對不起來,不敢換上去'
'(%s 一個位元組都沒有被動到)' % dst)
_replace_live(tmp, dst)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
# Windows 的命令提示字元預設用 cp950,直接印中文會炸掉。
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except Exception:
pass
class DataError(Exception):
"""檔案內容跟預期不符。一律當成「停下來問人」,不要猜。"""
# 備份的副檔名。本站每一支工具各用各的,免得兩支工具的備份在同一個資料夾裡
# 互相覆蓋,那會讓「還原」變成「拿別的東西蓋掉正本」。
BACKUP_SUFFIX = '.audiobak'
GROUP_SAMPLES = 28 # 一組 15 bytes = 1 表頭 + 14 資料 = 28 個半位元組
GROUP_BYTES = 15
# ─────────────────────────────────────────────────────────
# 取樣的數學
#
# 每一組:表頭 1 byte,高半位元組挑係數(0-3),低半位元組決定位移。
# c1 = EA[h >> 4] c2 = EA[(h >> 4) + 4]
# 位移 = 20 - (h & 0x0F)
# 接著 14 個 bytes,每個位元組拆成兩個半位元組(**高的先**):
# s = clip( ((sign4(nib) << 位移) + cur*c1 + prev*c2) >> 8 )
# prev, cur = cur, s
#
# ⚠️ 位移是 20 減去低半位元組,也就是**低半位元組越大、位移越小**。
# 本站以前試過的一千種組合裡有一大類寫成相反方向,那是失敗的主因之一。
# ─────────────────────────────────────────────────────────
EA_COEF = [0, 240, 460, 392, 0, 0, -208, -220]
# 前四個是 c1(乘在「上一個取樣」上),後四個是對應的 c2(乘在「再上一個」上),
# 所以 c2 一律取 EA_COEF[idx + 4]。這張表不是猜的:拿 ffmpeg 解出來的同一段
# 音樂當標準答案,1,024 種組合裡只有這一組給出 100.00%,第二名 96.90%,
# 第三名掉到 18.91%。不是勉強領先,是斷崖。
# 半位元組是有號的:0-7 就是 0 到 7,8-15 是 -8 到 -1(四位元的二補數)。
# 少了這一步,所有負的取樣都會變成很大的正數。
def _sx4(v):
return v - 16 if v >= 8 else v
# 取樣是 16 位元有號整數,算出來超出範圍就夾住。
# (本站沒有單獨驗過「夾住」這一步在真實資料上被觸發過幾次,
# 驗過的是整體解碼跟 ffmpeg 逐取樣相同:21,678,328 個取樣 0 個不同。)
def _clip(v):
return -32768 if v < -32768 else (32767 if v > 32767 else v)
# 一組的解碼。四個事實讀者從程式碼本身看不出來:
# · 表頭的高半位元組挑係數,**只可能是 0-3**;超出就代表相位切錯了
# · 表頭的低半位元組決定位移,而且是 20 減去它(不是加)
# · 14 個資料位元組,每個拆成兩個半位元組,**高的先**
# · 每個取樣都要靠前兩個取樣算出來,所以 prev/cur 得一路傳下去:
# 不能從中間任意一組開始解,那是這個格式的性質不是實作偷懶
def decode_group(header, payload, prev, cur):
"""15 bytes → 28 個取樣。payload 是 14 bytes。"""
idx = header >> 4
if idx > 3:
raise DataError('表頭高半位元組是 %d,超過 0-3 —— 相位切錯了' % idx)
c1, c2 = EA_COEF[idx], EA_COEF[idx + 4]
sh = 20 - (header & 0x0F)
out = []
for k in range(GROUP_SAMPLES):
b = payload[k >> 1]
nib = (b >> 4) if not (k & 1) else (b & 0x0F)
s = _clip(((_sx4(nib) << sh) + cur * c1 + prev * c2) >> 8)
prev, cur = cur, s
out.append(s)
return out, prev, cur
def encode_group(samples, prev, cur):
"""28 個取樣 → 15 bytes。窮舉 4 個係數 × 16 個位移,取誤差平方和最小。
只有 64 種組合,而且每種只要跑 28 個取樣 —— 全掃比任何啟發式都省事,
而且結果是這個格式能表達的最好解,不是「夠好就收」。
"""
# 反過來做:先算「這一步理想上要加多少」(want),除以 2 的位移次方再四捨五入,
# 就是最接近的那個半位元組。夾到 -8..7 之後**要用夾過的值重新算一次取樣**。
# 這一步很重要:編碼器接下來用的 prev/cur 必須跟解碼器將來算出來的一模一樣,
# 不然誤差會一路累積下去。
best = None
for ci in range(4):
c1, c2 = EA_COEF[ci], EA_COEF[ci + 4]
for lo in range(16):
sh = 20 - lo
p, c, err, nibs = prev, cur, 0, []
for t in samples:
want = (t << 8) - c * c1 - p * c2
n = int(round(want / float(1 << sh)))
n = -8 if n < -8 else (7 if n > 7 else n)
s = _clip(((n << sh) + c * c1 + p * c2) >> 8)
err += (s - t) * (s - t)
p, c = c, s
nibs.append(n & 0x0F)
if best is None or err < best[0]:
best = (err, (ci << 4) | lo, nibs, p, c)
err, header, nibs, p, c = best
payload = bytes(((nibs[i * 2] << 4) | nibs[i * 2 + 1]) for i in range(14))
return header, payload, p, c, err
# ─────────────────────────────────────────────────────────
# 容器:SCHl 檔頭 → SCCl → 一連串 SCDl → SCEl
#
# 檔頭是「標籤, 長度, 值」的串列,值是大端序。已知的標籤:
# 0x80 編碼器(1 = 音樂那種 · 2 = 語音/音效那種 · 3 = 社群語音包用的那種;裝在哪種容器看容器標記,不看編碼器)
# 0x82 聲道數(沒有這一項就是單聲道)
# 0x84 取樣率(沒有就用預設)
# 0x85 取樣總數
# 0xFF 結束
# ⚠️ 0x80 的意義是本站量出來的。在**剛安裝好的原版**上,值為 1 的段剛好就是
# menu1 到 menu9 九個選單音樂,一個不多一個不少(英文版與中文版各 31,034
# 段,兩份都是 9 段編碼器 1,而且都是那九個檔)—— 是名字對上名字,
# 不是數量湊巧。
# ⚠️ 但**裝了模組的機器上不只九個**:本站測試機那份 data/audio 有 464 個
# 編碼器 1 的段,除了那九首之外,還有 61 段在 cd/spch_pa/pnamedat.big、
# 394 段在 cd/spch_pbp/pnamedat.big,那是被換過的球員名字播報語音,
# 而且是單聲道。所以「編碼器 1 = 音樂」在原版成立,在改過的機器上不成立。
# ⚠️ 另外:「每塊自帶起始狀態」跟編碼器編號無關,是**看聲道數**決定的
# (見 decode_segment)。連剛安裝好的原版英文版都有 296 段編碼器 2 是立體聲;
# 把「原版英文版 data/audio + 測試機 data/audio + 測試機收藏的社群語音」
# 三棵樹加起來(71,561 段)是編碼器 2 有 487 段立體聲、編碼器 1 有 455 段
# 單聲道。所以不可以拿編號去猜起始狀態怎麼取。
# ─────────────────────────────────────────────────────────
def parse_header(blob, off=0):
if blob[off:off + 4] != b'SCHl':
raise DataError('開頭不是 SCHl(是 %r)' % blob[off:off + 4])
# 區塊本身的長度欄位一律小端(SCHl/SCCl/SCDl/SCEl 都是),
# **但區塊裡面的數值不一定**,見下面那段位元組序的訂正。
ln = struct.unpack('<I', blob[off + 4:off + 8])[0]
p = off + 8
# 檔頭的前 4 個位元組是平台/容器標記(例如 PT 或 GSTR)。
# 它決定了底下 SCDl 裡的數字要用哪一種位元組序讀。
platform = blob[p:p + 4]
p += 4
# 接下來是「標籤, 長度, 值」一路串到 0xFF 為止。
# 0xFC / 0xFD / 0xFE 是單獨一個位元組,沒有長度也沒有值,遇到就跳過
# (本站只知道它們不帶值,沒有查出它們各自代表什麼)。
tags, end = {}, off + ln
while p < end:
t = blob[p]
if t == 0xFF:
break
if t in (0xFC, 0xFD, 0xFE):
p += 1
continue
if p + 1 >= end:
break
L = blob[p + 1]
tags[t] = int.from_bytes(blob[p + 2:p + 2 + L], 'big')
p += 2 + L
# ── 位元組序跟「容器子型別」綁在一起(2026-08-30 量到)──────────────
# 本工具原本無條件用小端讀 SCDl 的取樣數與聲道起點。
# 那對原版的 PT 容器是對的,對**社群語音包的 GSTR 容器是錯的** ——
# 而錯的後果不是報錯,是 n // 28 算出天文數字或 0,整段被 continue 跳過,
# 最後印一句「這一段解不出取樣」。也就是說:
# **本工具在社群語音包上從來沒有成功過,而且看起來像是格式沒解開。**
#
# 實測(每個容器各抽 50 個以上):
# PT 容器 → 小端,26/26 成立
# GSTR 容器 → 大端,100/100 成立,零例外
# 例:許銘傑 8254c.dat 第一段前 4 bytes 是 00 00 25 84 ——
# 大端讀出 9,604 個取樣(÷28 = 343 組,對得上 body 長度);
# 小端讀出 2,217,017,344 個,顯然不是取樣數。
endian = '>' if platform[:4].startswith(b'GSTR') else '<'
return {'platform': platform.decode('latin-1', 'replace'),
'endian': endian,
'header_len': ln,
'codec': tags.get(0x80),
'channels': tags.get(0x82, 1),
'rate': tags.get(0x84),
'samples': tags.get(0x85),
'tags': tags}
# 一段的內部是四種區塊接龍:SCHl(檔頭)→ SCCl → 一個或很多個 SCDl(資料)
# → SCEl(結束)。每個區塊前 8 個位元組是「四個字母的標籤 + 長度」,
# 長度含標籤自己,所以下一個區塊的位置就是 p + ln。
# 遇到不認得的標籤就停。寧可少走,也不要把後面的東西當成資料解。
def walk(blob, off=0):
"""回傳這一段的 [(標籤, 起點, 長度)],走到 SCEl 為止。"""
out, p = [], off
while p + 8 <= len(blob):
tag = blob[p:p + 4]
if tag not in (b'SCHl', b'SCCl', b'SCDl', b'SCEl'):
break
ln = struct.unpack('<I', blob[p + 4:p + 8])[0]
if ln <= 0 or p + ln > len(blob):
break
out.append((tag.decode('latin-1'), p, ln))
p += ln
if tag == b'SCEl':
break
return out
# 一個檔案裡可以串很多段(一個球員的很多句話就是這樣放的)。
# 做法是一路找 SCHl,找到就走完它的接龍,再從那一段的結尾繼續找下一個。
def segments(blob):
"""一個檔裡可能串著很多段。回傳每段的起點。"""
out, p = [], 0
while True:
i = blob.find(b'SCHl', p)
if i < 0:
break
out.append(i)
chain = walk(blob, i)
p = (chain[-1][1] + chain[-1][2]) if chain else i + 4
return out
def _phase_of(body, ngroups):
"""這一塊的資料從第幾個位元組開始。
判準:唯一能讓**每一個**表頭的高半位元組都落在 0-3 的位置。
這不是猜 —— 切錯相位的話,53 個表頭裡幾乎一定有人超出 0-3。
"""
# 判準的來源:每一組的第一個位元組是表頭,而表頭的高半位元組只可能是 0-3。
# 所以「資料從第幾個位元組開始」可以用這條性質掃出來:
# 切錯位置的話,幾十個表頭裡幾乎一定會有人超出 0-3。
cands = [pre for pre in range(0, len(body) - ngroups * GROUP_BYTES + 1)
if all((body[pre + g * GROUP_BYTES] >> 4) <= 3 for g in range(ngroups))]
if not cands:
return None
# ⚠️ 全靜音的區塊每個位置都通得過(表頭是 0,高半位元組當然 ≤3),
# 判準在這種資料上失去鑑別力。4 是實測的常態(每塊 4 bytes 前置),
# 所以 4 在候選裡就用 4,掃描只是備援。
# 2026-08-29:原本無條件取第一個,在兩個靜音區塊上切到位置 0,
# 產生 376 個錯誤取樣(整體 0.0017%)—— 少數但確實是錯的。
return 4 if 4 in cands else cands[0]
# 把一整段解成「每個聲道一串取樣」。兩件事跟直覺不一樣:
# · 立體聲的兩個聲道在 SCDl 裡是**各自連續**的兩大塊,不是左右交錯。
# body 的前 12 個位元組給了取樣數與兩塊各自的起點。
# · 起始狀態:立體聲每一塊開頭 4 個位元組就是這一塊自己的起始狀態
# (「上一個取樣」與「再上一個」,兩個 16 位元有號小端);
# 單聲道則是狀態跨區塊延續,那 4 個位元組不是種子。
# 本工具用「是不是立體聲」當判準,不是看檔頭那個編碼器編號。
# 這是實作上的選擇,本站沒有另外驗過兩種分法是不是永遠等價。
def decode_segment(blob, start):
"""回傳 (檔頭資訊, 每個聲道的取樣串列)。"""
info = parse_header(blob, start)
en = info['endian']
chain = walk(blob, start)
chans = max(1, info['channels'])
outs = [[] for _ in range(chans)]
state = [(0, 0) for _ in range(chans)]
skipped = 0
for tag, off, ln in chain:
if tag != 'SCDl':
continue
body = blob[off + 8:off + ln]
n = struct.unpack(en + 'I', body[:4])[0]
ng = n // GROUP_SAMPLES
if ng == 0:
continue
if chans == 2:
lo, ro = struct.unpack(en + 'II', body[4:12])
parts = [body[12 + lo:12 + ro], body[12 + ro:]]
else:
parts = [body[4:]]
for ci, part in enumerate(parts):
pre = _phase_of(part, ng)
if pre is None:
skipped += 1
continue
if chans == 2 and pre >= 4:
# 立體聲:每塊開頭 4 bytes 就是這一塊的起始狀態
cur0, prev0 = struct.unpack('<hh', part[pre - 4:pre])
prev, cur = prev0, cur0
else:
prev, cur = state[ci]
q = pre
for _ in range(ng):
h = part[q]
seg, prev, cur = decode_group(h, part[q + 1:q + 15], prev, cur)
q += GROUP_BYTES
outs[ci].extend(seg)
state[ci] = (prev, cur)
info['skipped_blocks'] = skipped
return info, outs
# ─────────────────────────────────────────────────────────
# WAV:自己讀自己寫,不需要安裝任何套件
# ─────────────────────────────────────────────────────────
# 寫 WAV 用 Python 內建的 wave 模組,所以零相依。
# WAV 的兩個聲道是**交錯**的(左右左右…),跟遊戲格式的「各自連續」正好相反,
# 所以這裡要一個一個穿插回去。長度取兩個聲道的較短者,免得對不齊。
# ⚠️ 這裡**不可以**直接 wave.open(path, 'wb')(2026-09-06 第三輪稽核改):
# 那一行內部就是 open(path, 'wb'),**會跟著符號連結走**。_check_out_path 已經
# 先擋過一次,但那是「看過一眼」與「真的開檔」之間的兩個時刻,中間那段空窗
# 足夠讓別人把那個名字換成一個指向資料夾外面的連結,外面那個檔就被截成 0。
# 改成跟遊戲檔同一套:mkstemp(O_CREAT|O_EXCL,不跟連結走)→ 寫 → fsync →
# os.replace。換名只換掉那個名字本身,資料夾外面的檔不會被動到。
# (匯出的 WAV 不是遊戲檔,所以這裡不登記 _LIVE_REPLACED —— 那面旗子講的是
# 「遊戲檔換掉了沒有」,拿匯出檔去舉它會讓 Ctrl-C 的收尾說錯話。)
def write_wav(path, chans, rate):
path = os.fspath(path)
_reject_symlink(path, '輸出檔')
fd, tmp = _mkstemp_beside(path, 'wav')
try:
with os.fdopen(fd, 'wb') as raw:
with wave.open(raw, 'wb') as w:
w.setnchannels(len(chans))
w.setsampwidth(2)
w.setframerate(rate or 22050)
n = min(len(c) for c in chans)
buf = bytearray()
for i in range(n):
for c in chans:
buf += struct.pack('<h', c[i])
w.writeframes(bytes(buf))
raw.flush()
os.fsync(raw.fileno())
# mkstemp 開出來的檔是 0600(只有自己讀得到),而匯出的 WAV 是要拿去用的。
# 目的檔已經存在就抄它原本的權限,沒有就用 0644 —— 跟一般 umask 022 底下
# open(..., 'wb') 建出來的一樣,讀者不會發現有什麼不同。
try:
if os.path.exists(path):
shutil.copymode(path, tmp)
else:
os.chmod(tmp, 0o644)
except OSError:
pass
os.replace(tmp, path)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
# 讀 WAV。只吃 16 位元,8 位元或 32 位元浮點一律拒絕,
# 因為本工具不做格式轉換,那件事應該由你在音訊軟體裡做完再進來。
# 最後一行 all_s[c::nch] 是把交錯的取樣拆回「一個聲道一串」。
def read_wav(path):
# 讀者最常打錯的兩件事在這裡先擋成一句中文:WAV 檔名打錯、丟了一個
# 根本不是 WAV 的檔。不擋的話 wave 模組會分別丟 FileNotFoundError 與
# EOFError 的 traceback,而這一課的讀者是「不能要求開 terminal」的那群人。
if not os.path.isfile(path):
raise DataError('找不到這個 WAV 檔:%s' % path)
try:
with wave.open(path, 'rb') as w:
if w.getsampwidth() != 2:
raise DataError('只吃 16 位元的 WAV(這個是 %d 位元)'
% (w.getsampwidth() * 8))
nch, rate, n = w.getnchannels(), w.getframerate(), w.getnframes()
raw = w.readframes(n)
except (wave.Error, EOFError):
# DataError 不會被這裡吃掉(它不是 wave.Error 也不是 EOFError),
# 所以上面那句「只吃 16 位元」照樣送得出去。
raise DataError('讀不出 %s 的 WAV 結構 —— 它可能不是 WAV,或是壞的' % path)
all_s = struct.unpack('<%dh' % (len(raw) // 2), raw)
return [list(all_s[c::nch]) for c in range(nch)], rate
# ─────────────────────────────────────────────────────────
# 指令
# ─────────────────────────────────────────────────────────
# 檔頭標籤 0x80 的值。意義是本站量出來的:在剛安裝好的原版上,值為 1 的段
# 剛好就是 menu1 到 menu9 九個選單音樂,一個不多一個不少(名字對上名字,
# 不是數量湊巧);3 是裝在 GSTR 容器裡的那種(2026-08-30 之前一直被誤會成
# 「格式沒解開」)。
# ⚠️ 這裡**刻意不寫「起始狀態怎麼取」**:那件事看的是聲道數不是編碼器編號
# (見 decode_segment)。本站在 parse_header 上面那段講的三棵樹(71,561 段)
# 量到編碼器 1 有 455 段是單聲道、編碼器 2 有 487 段是立體聲,拿編號去猜會猜錯。
# ⚠️ 也刻意不寫「編碼器 1 就是音樂」:那只在原版成立,裝了模組的機器上
# 球員名字的播報語音也有 455 段是編碼器 1(見 parse_header 上面那段)。
CODEC_NAME = {1: '1(原版只有九首選單音樂用它)',
2: '2(語音/音效那種)',
3: '3(社群語音包用的那種)'}
# --info:只看第一段的檔頭。最便宜的一步,先確認「這確實是 EA 的聲音檔」,
# 再決定要不要往下做。
def cmd_info(path):
blob = open(path, 'rb').read()
segs = segments(blob)
print('\n 檔案 %s bytes · 裡面有 %d 段' % (format(len(blob), ','), len(segs)))
if not segs:
print(' 找不到 SCHl —— 這不是 EA 的聲音檔\n')
return 1
info = parse_header(blob, segs[0])
print(' 平台標記 %s' % info['platform'])
print(' 編碼器 %s' % CODEC_NAME.get(info['codec'], info['codec']))
print(' 聲道 %s' % info['channels'])
print(' 取樣率 %s' % (info['rate'] or '(檔頭沒寫)'))
print(' 取樣總數 %s' % (format(info['samples'], ',') if info['samples'] else '?'))
print(' 其餘標籤 %s\n' % {hex(k): v for k, v in info['tags'].items()
if k not in (0x80, 0x82, 0x84, 0x85)})
return 0
# --list:一個檔裡每一段各印一行。最多印 200 段,再多就只報還有幾段:
# 一個檔裡常常串著上萬段,全印出來沒有人看得完。
def cmd_list(path):
blob = open(path, 'rb').read()
segs = segments(blob)
print('\n 編號 起點 區塊數 編碼器 聲道 取樣率 取樣數')
print(' ' + '-' * 60)
for i, s in enumerate(segs[:200]):
info = parse_header(blob, s)
nblk = sum(1 for t, _, _ in walk(blob, s) if t == 'SCDl')
print(' %-5d %-11s %-7d %-7s %-5s %-8s %s'
% (i, format(s, ','), nblk, info['codec'], info['channels'],
info['rate'] or '-',
format(info['samples'], ',') if info['samples'] else '?'))
if len(segs) > 200:
print(' …還有 %d 段' % (len(segs) - 200))
print()
return 0
# 輸出檔名的守門。--export 是唯一一條「不用 --apply 也會寫檔」的路,
# 而它以前完全不看輸出檔名就覆寫:讀者只要手滑把輸出打成遊戲資料夾裡既有的
# 檔名(.ast / .exe / .sav 都可以),那個檔會被 WAV 內容整個蓋掉,而且 exit 0
# 什麼都不說(2026-09-05 實測:14 bytes 的 precious.txt 變成 74,188 bytes 的 WAV)。
# 現在四件事都擋:
# 0. 輸出不可以是符號連結。⚠️ 這一道不能靠下面那個 os.path.exists():
# 連結指到一個不存在的檔時 exists() 回 False,整段檢查會被跳過,
# 而 wave 模組照樣會沿著連結在資料夾外面建出一個檔。要用 islink 看連結自己。
# 1. 輸出不可以就是輸入那個遊戲檔(用 os.path.samefile 比,不是比字串,
# 這樣 ./x.dat 跟 x.dat 也認得出來是同一個)
# 2. 輸出的資料夾要存在(不存在的話 wave 模組會丟一句
# 「'Wave_write' object has no attribute '_file'」,沒有人看得出那是路徑問題)
# 3. 輸出檔要是**已經存在而且不是 WAV**,拒絕。已經是 WAV 就讓它蓋 ——
# 重跑一次 --export 覆蓋上一次的輸出是正常用法,擋掉反而礙事。
def _check_out_path(src_path, out):
_reject_symlink(out, '輸出檔')
if os.path.exists(out):
try:
if os.path.samefile(src_path, out):
raise DataError('輸出檔名就是那個遊戲檔本身(%s)—— 拒絕,'
'這會把它整個蓋掉' % out)
except OSError:
pass
if not os.path.isfile(out):
raise DataError('%s 已經存在而且不是一般檔案 —— 拒絕寫入' % out)
with open(out, 'rb') as f:
head = f.read(12)
if not (head[:4] == b'RIFF' and head[8:12] == b'WAVE'):
raise DataError('%s 已經存在,而且它不是 WAV 檔 —— 拒絕蓋掉它。'
'請換一個輸出檔名' % out)
d = os.path.dirname(os.path.abspath(out))
if not os.path.isdir(d):
raise DataError('輸出的資料夾不存在:%s' % d)
# --export:把一段解成 WAV。完全不動遊戲檔。
# 「有 N 個區塊切不出相位,已跳過」那一句很重要:它代表匯出的長度短了一截,
# 而那正是 cmd_import 拒絕寫回去的理由。
def cmd_export(path, idx, out):
_check_out_path(path, out)
blob = open(path, 'rb').read()
segs = segments(blob)
if idx >= len(segs):
raise DataError('只有 %d 段,沒有第 %d 段' % (len(segs), idx))
info, chans = decode_segment(blob, segs[idx])
if not any(chans):
raise DataError('這一段解不出取樣(可能是本工具還沒處理的排列方式)')
write_wav(out, chans, info['rate'])
n = min(len(c) for c in chans)
print(' 第 %d 段 → %s' % (idx, out))
print(' 編碼器 %s · %d 聲道 · %s Hz · %s 取樣 · %.2f 秒'
% (info['codec'], len(chans), info['rate'] or 22050,
format(n, ','), n / float(info['rate'] or 22050)))
if info['skipped_blocks']:
print(' ⚠ 有 %d 個區塊本工具切不出相位,已跳過(匯出的長度會短一些)'
% info['skipped_blocks'])
return 0
# --import:把 WAV 編回某一段。它是檢查最多的一條:聲道數、取樣率、相位對不對齊,
# 三關都過才准寫;而且還要再加 --apply。
# ⚠️ 但取樣率那一關是**有條件**的:下面寫的是 if info['rate'] and ...,
# 那一段的檔頭沒寫取樣率時,這一關會整個跳過。本站在剛安裝好的原版那份
# data/audio 上量到 31,034 段裡有 30,824 段沒寫(99.3%),其中 220 段連
# 相位那一關也過得去,只要聲道數對,丟任何取樣率進去都不會被擋(實測
# cd/aems/batdit.ast 第 0 段吃一個 8000 Hz 的立體聲 WAV,直接放行)。
# 精確講是「聲道數與位元深度一定擋,取樣率看那一段的檔頭有沒有寫」。
# ⚠️ 但**會動遊戲檔的不只它一條**,還有 --restore。檔頭「安全網」第 1 條就是
# 這樣寫的:cmd_restore() 不做上面那三關,直接把 .audiobak 整份蓋回正本。
# 本站拿 8254c.dat 的複本實測(67,324 bytes,歷史資料裡許銘傑那一份語音檔):
# 把正本改壞一個位元組再跑 --restore,md5 回到備份那一份的值。
def cmd_import(path, idx, src, apply_it):
blob = open(path, 'rb').read()
segs = segments(blob)
if idx >= len(segs):
raise DataError('只有 %d 段,沒有第 %d 段' % (len(segs), idx))
info = parse_header(blob, segs[idx])
chans_in, rate = read_wav(src)
if len(chans_in) != max(1, info['channels']):
raise DataError('這一段是 %d 聲道,你的 WAV 是 %d 聲道 —— 拒絕寫入'
% (info['channels'], len(chans_in)))
if info['rate'] and rate != info['rate']:
raise DataError('這一段是 %d Hz,你的 WAV 是 %d Hz —— 拒絕寫入,本工具不幫你重取樣'
% (info['rate'], rate))
# 對齊守門:寫之前先整段試解一次,只要有任何一塊切不出相位就停手。
# ⚠️ 2026-08-29:匯出時若有區塊切不出相位會被跳過,那會讓 WAV 的第 0 個取樣
# 其實對應到第 1 個區塊 —— 再寫回去就整段錯位(實測來回訊噪比 -3.1 dB)。
# 對不齊就不寫。這比「盡量對」安全:錯位的聲音聽起來像壞掉,而且無從追查。
probe, _chans = decode_segment(blob, segs[idx])
if probe.get('skipped_blocks'):
raise DataError('這一段有 %d 個區塊本工具切不出相位 —— 無法保證寫回去的位置對齊,'
'拒絕寫入' % probe['skipped_blocks'])
# 這一段能裝多少取樣是**固定的**:每個 SCDl 的前 4 個位元組寫著它的取樣數,
# 全部加起來就是「格子總長」。格子不能變大,所以你的錄音只會被補靜音或被切掉。
#
# ── 2026-09-04 訂正:寫回去這一段以前無條件用小端讀取樣數 ──────────
# 解碼那邊(parse_header 的 endian)早就改成看容器決定位元組序,寫入沒跟著改。
# 舊註解寫的「本站尚未逐檔驗證影響範圍」已經作廢:影響範圍是每一個 GSTR 檔。
# 小端讀出來是天文數字 → n // 28 大到 _phase_of 連候選都排不出來 →
# 每一塊都被跳過 → 檔案一個位元組都沒改,而畫面照樣印「✅ 寫好了」。
#
# 量法:本站測試機上收藏的模組與語音檔裡,開頭是 SCHl 的檔共 1,129 個,
# 每一個都先 --export 第 0 段成 WAV,再把那個 WAV 原封不動 --import --apply
# 回去(所以聲道數與取樣率一定相符),比對前後 md5。用修之前的程式碼:
# GSTR 967 個 → 沒有一個檔被改到位元組。191 個一路印到「✅ 寫好了」
# 而 md5 前後相同,772 個在相位守門被明說拒絕,
# 另外 4 個連匯出都明說解不出來,沒有進到匯入
# PT 取 2 MB 以下的 47 個當對照組 → 過得了守門的 4 個都真的改到位元組
# 例:張誌家 8433a.dat 印「格子總長 5,591,793,664 取樣」「✅ 寫好了
# (5,591,793,592 取樣)」,而寫入前後 md5 都是 268cefc3 開頭那一個。
en = info['endian']
chain = walk(blob, segs[idx])
scdl = [(o, l) for t, o, l in chain if t == 'SCDl']
total = 0
for o, l in scdl:
total += struct.unpack(en + 'I', blob[o + 8:o + 12])[0]
have = min(len(c) for c in chans_in)
# ⚠️ 那一段的檔頭沒寫取樣率時,這裡印的 Hz 是**你來源 WAV 的取樣率**
# (info['rate'] or rate),不是從那一段量到的。看到的數字剛好跟你丟
# 進來的一樣時就是這個原因,不要把它當成那一段的規格。
print(' 目標 第 %d 段(編碼器 %s · %d 聲道 · %s Hz)'
% (idx, info['codec'], len(chans_in), info['rate'] or rate))
print(' 來源 %s(%s 取樣)' % (src, format(have, ',')))
print(' 這一段的格子總長 %s 取樣' % format(total, ','))
if have < total:
print(' ⚠ 你的比較短,不足的部分會補靜音')
elif have > total:
print(' ⚠ 你的比較長,超出的會被切掉(格子是定長的,不能變大)')
if not apply_it:
print('\n 這是預覽,沒有動到任何檔案。確定要寫就加 --apply\n')
return 0
bak = path + BACKUP_SUFFIX
# 兩個都要看,理由不一樣:
# · 正本是符號連結 → os.replace 只會把連結本身換成一個真的檔,
# 連結原本指到的那個遊戲檔反而留著舊內容,讀者會以為換好了。
# · 備份是符號連結 → --restore 會從資料夾外面讀東西回來蓋正本。
_reject_symlink(path, '要寫入的那個遊戲檔')
_reject_symlink(bak, '備份檔')
if not os.path.exists(bak):
_atomic_copy(path, bak)
print(' 已備份 → %s' % os.path.basename(bak))
# 原地填格子:整個檔先讀進記憶體,只覆寫每個 SCDl 裡面那些 15 個位元組的組,
# 區塊長度、容器結構、後面所有段的位移全部不動。
# (專案鐵律:封裝檔不重新打包,重打包會把你不知道的東西弄丟。)
buf = bytearray(blob)
pos = 0
state = [(0, 0) for _ in chans_in]
worst = 0
wrote = 0 # 真的被覆寫掉的「15 個位元組一組」有幾組
for o, l in scdl:
body = bytearray(buf[o + 8:o + l])
# 取樣數與兩個聲道的起點,跟解碼讀的是同一批欄位,位元組序當然要同一個。
n = struct.unpack(en + 'I', bytes(body[:4]))[0]
ng = n // GROUP_SAMPLES
if ng == 0:
continue
if len(chans_in) == 2:
lo, ro = struct.unpack(en + 'II', bytes(body[4:12]))
spans = [(12 + lo, 12 + ro), (12 + ro, len(body))]
else:
spans = [(4, len(body))]
for ci, (a, b) in enumerate(spans):
part = bytes(body[a:b])
pre = _phase_of(part, ng)
if pre is None:
continue
# ⚠️ 起始狀態要跟**解碼端將來會用的那一個**一模一樣,否則每個區塊
# 的開頭都會對不上。decode_segment 對立體聲且 pre>=4 的區塊,
# 是從這一塊開頭那 4 個位元組讀出它自己的起始狀態;寫入端本來
# 卻一律用跨區塊延續的 state[ci],而且從來不重寫那 4 個位元組。
# 實測(2026-09-05,menu9.asf 的第 1000~1004 個 SCDl 組成的
# 5 區塊測試檔):--export → --import --apply → --export 之後,
# 29,400 個取樣有 486 個不同、最大誤差 11,436(訊號峰值 32,052),
# 而且誤差全部集中在區塊開頭再往後衰減。改用同一個種子之後
# 29,400 個取樣 0 個不同。
# 那 4 個位元組在 q 前面,寫入迴圈從 q 開始,所以不會被蓋掉。
if len(chans_in) == 2 and pre >= 4:
cur0, prev0 = struct.unpack('<hh', part[pre - 4:pre])
prev, cur = prev0, cur0
else:
prev, cur = state[ci]
q = a + pre
for g in range(ng):
seg = []
for k in range(GROUP_SAMPLES):
j = pos + g * GROUP_SAMPLES + k
seg.append(chans_in[ci][j] if j < len(chans_in[ci]) else 0)
h, pay, prev, cur, err = encode_group(seg, prev, cur)
body[q] = h
body[q + 1:q + 15] = pay
q += GROUP_BYTES
worst = max(worst, err)
wrote += 1
state[ci] = (prev, cur)
buf[o + 8:o + l] = bytes(body)
pos += ng * GROUP_SAMPLES
# ⚠️ 一組都沒有寫進去就不准印成功(2026-09-04 加的網)。
# 上面那個位元組序 bug 的長相就是這樣:每一塊都被跳過、檔案一個位元組都沒變,
# 畫面卻印「✅ 寫好了」。以後再有哪一種容器分支讓寫入整段落空,
# 這裡會擋下來,而不是讓你以為換好了。
if wrote == 0:
raise DataError('這一段一個位元組都沒有被寫進去,拒絕印成功。'
'請把這個檔回報給本站,不要當成已經換好了')
# 先寫一個暫存檔,寫完了才用 os.replace 換成正式名字 —— 跟 _atomic_copy
# 同一套。直接 open(path, 'wb') 的話,**那一行呼叫下去檔案當場就變 0 bytes**,
# 之後才慢慢寫回去;中途被中斷(按 Ctrl-C、磁碟滿、外接碟拔掉)就會留下
# 一個半截的遊戲檔。這支工具處理的檔可以到 24 MB
# (data/audio/cd/aems/crowd4a.ast),那個窗口不是瞬間。
# 備份還是排在寫入之前,兩道是互補的:.audiobak 保住舊內容,
# 這一段保住「正本要嘛是舊的、要嘛是新的,不會是半截的」。
# ⚠️ 2026-09-05:暫存檔的名字以前是 path + '.part',**猜得到**;
# 有人先在那裡放一個指向資料夾外面的符號連結,open(...,'wb') 就會沿著它
# 把外面的檔截成 0。現在改用 mkstemp(O_CREAT|O_EXCL,不跟連結走)。
new_bytes = bytes(buf)
fd, tmp_path = _mkstemp_beside(path, 'write')
try:
with os.fdopen(fd, 'wb') as f:
f.write(new_bytes)
f.flush()
os.fsync(f.fileno()) # 少了這一步,斷電之後換上去的可能是空殼
try:
# 換名之後權限跟著新檔走,所以先把正本的權限抄過去。
shutil.copymode(path, tmp_path)
except OSError:
pass
_replace_live(tmp_path, path)
except BaseException:
try:
os.remove(tmp_path)
except OSError:
pass
raise
# 寫完之後把檔案讀回來,跟「本來要寫的那一份」逐位元組比對。
# 對不起來就用備份換回去,並以非 0 的離開碼收場 —— 不可以只印一句 ❌
# 然後照樣回報成功,那會讓批次腳本以為這一步過了,繼續往下做。
_verify_written(path, new_bytes, bak)
print(' ✅ 寫好了(%s 取樣)。要還原就跑 --restore\n' % format(pos, ','))
return 0
def _verify_written(path, expect, bak):
"""寫完之後的複驗。對不起來就自動還原,並讓整支以非 0 收場。
為什麼要有這一道:os.replace 成功不等於磁碟上的內容就是你要的
(檔案系統滿了、外接碟中途掉線、防毒軟體攔截改寫,都做得出這種事)。
複驗讀的是磁碟,不是記憶體裡那份 buf。
"""
try:
with open(path, 'rb') as f:
got = f.read()
except OSError as err:
why = '讀不回來(%s)' % err
else:
if got == expect:
return
if len(got) != len(expect):
why = ('磁碟上是 %s bytes,本來要寫的是 %s bytes'
% (format(len(got), ','), format(len(expect), ',')))
else:
# 長度一樣的時候要說出「第幾個位元組開始不一樣」,
# 不然畫面會印出兩個一模一樣的數字,讀者看不懂到底哪裡不對。
bad = next(k for k in range(len(got)) if got[k] != expect[k])
why = ('長度一樣都是 %s bytes,但從第 %s 個位元組起就跟本來要寫的不同'
% (format(len(got), ','), format(bad, ',')))
try:
_restore_from_backup(bak, path)
undone = ' 已經自動用 %s 把它換回去了。\n' % os.path.basename(bak)
except BaseException as err:
undone = (' ⚠️ 自動還原也失敗了(%s),請自己跑:\n'
' python3 mvp_audio.py "%s" --restore\n' % (err, path))
raise SystemExit(
'寫完之後複驗對不起來:%s。\n%s'
' 請不要繼續用這個檔,改用 --restore 或你自己另外留的那一份備份。'
% (why, undone))
def _audio_container_error(path):
"""用格式驗這個檔完不完整。
回傳 None = 裡面找不到 SCHl,沒有格式可以驗(這種檔本工具本來也處理不了)
回傳 '' = 驗過而且完整
回傳一句話 = 驗過而且壞了,那句話說它壞在哪
判準:每一段的檔頭有一個標籤 0x85 寫著「這一段有幾個取樣」,
而那一段每個 SCDl 的前 4 個位元組各自寫著自己那一塊有幾個取樣。
後者加起來必須等於前者 —— 檔案被截斷就會少掉尾巴那幾塊,兩個數字對不上。
⚠️ 為什麼不用「最後一塊是不是 SCEl」當判準:本站收藏的社群語音裡有兩個檔
(邱子愷 3499a.dat / 3499d.dat)本來就沒有 SCEl 收尾,拿那個當判準會把
它們的合法備份也擋掉。
⚠️ 這個判準量過三棵樹:剛安裝好的原版英文版 data/audio(31,034 段)、
測試機的 data/audio(40,458 段)、測試機收藏的社群語音(69 段),
合計 71,561 段,每一段的「宣告值」與「加總值」都相等。
⚠️ 它擋得住「尾巴被切掉」,擋不住「中間被改壞但長度沒變」——
真正保得住你的,還是你自己另外留的那一整包備份。
"""
try:
with open(path, 'rb') as f:
data = f.read()
except OSError as err:
return '讀不到 %s(%s)。' % (path, err)
if b'SCHl' not in data:
return None
starts = segments(data)
if not starts:
return None
for st in starts:
try:
info = parse_header(data, st)
except (DataError, struct.error, IndexError):
return '位移 %d 的那一段檔頭讀不出來。' % st
chain = walk(data, st)
if not chain or chain[0][0] != 'SCHl':
return '位移 %d 的那一段區塊接龍讀不出來。' % st
decl = info.get('samples')
if not decl:
# 檔頭沒寫取樣總數就沒有東西可以比。本站量到的三棵樹裡沒有這種段,
# 真遇到就跳過這一段,不要假裝驗過。
continue
total = 0
for tag, off, ln in chain:
if tag != 'SCDl':
continue
try:
total += struct.unpack(info['endian'] + 'I', data[off + 8:off + 12])[0]
except struct.error:
return '位移 %d 的那一段有一塊 SCDl 讀不出取樣數。' % st
if total != decl:
return ('位移 %d 的那一段檔頭宣告 %s 個取樣,實際只湊得出 %s 個'
'(尾巴被切掉了)。'
% (st, format(decl, ','), format(total, ',')))
return ''
# --restore:只認本工具自己做的 .audiobak。找不到就明說「沒有找到本工具做的備份」,
# 不去猜資料夾裡別的檔案。猜錯就是拿別的東西蓋掉你的遊戲檔。
def cmd_restore(path):
bak = path + BACKUP_SUFFIX
# 先擋符號連結,再問「在不在」。順序不能反:指向不存在的檔的連結,
# os.path.exists() 會說「不在」,那樣就會被當成「還沒備份過」而放行。
_reject_symlink(bak, '備份檔')
_reject_symlink(path, '要還原的那個檔')
if not os.path.lexists(bak):
print(' 沒有找到本工具做的備份\n')
return 1
# ── 用格式驗備份完不完整(2026-09-05 加)────────────────────────
# _restore_from_backup 的通用地板(「不得小於正本一半」)對這一支擋不住:
# 實測把 20,352 bytes 的 4631a.dat 的備份截成 60%(12,211 bytes)再 --restore,
# 三道結構檢查(BIGF / LOCH / MZ)都走不到,地板也過得去,
# 於是印「已還原」、exit 0,而遊戲檔當場從 20,352 變成 12,211 bytes。
# 這一支有格式可以驗,所以不該退到那條地板 —— 見 _audio_container_error。
why = _audio_container_error(bak)
if why:
raise SystemExit(
'這份備份是壞的,不敢拿它覆蓋 %s。\n'
' %s\n'
' 多半是備份途中被中斷(磁碟滿、外接碟拔掉、按了 Ctrl-C)。\n'
' 請改用你自己另外留的那一份備份。' % (path, why))
# 這支工具是純原地填格子,寫回去不改長度(檔頭「安全網」第 5 條),
# 所以備份跟正本的大小本來就該一樣,不一樣就代表有一邊被動過手腳。
# ⚠️ 但這一條**只在正本自己結構也完整的時候**才拿來擋:
# 正本被寫到一半正是最需要還原的時候,那時候大小一定對不上,
# 要是照樣攔下來,就變成「讀者最需要救援的時候把救援關掉」。
if why == '' and os.path.exists(path) and _audio_container_error(path) == '':
nb, nd = os.path.getsize(bak), os.path.getsize(path)
if nb != nd:
raise SystemExit(
'備份是 %d bytes,要被蓋掉的那個檔是 %d bytes —— 兩邊結構都完整\n'
' 卻不一樣大。這支工具不改長度,所以這種情形代表那個檔不是本工具\n'
' 改出來的(可能被別的工具動過)。不敢拿備份覆蓋 %s。' % (nb, nd, path))
_restore_from_backup(bak, path)
# 複驗:還原完之後正本必須跟備份**逐位元組**相同,而且比的是完整長度。
# (不可以用 zip() 那種「兩邊一起走、短的那邊先停」的比法 ——
# 少掉的尾巴永遠比不到,截斷會從頭到尾都是綠的。)
with open(bak, 'rb') as f1, open(path, 'rb') as f2:
same = (os.path.getsize(bak) == os.path.getsize(path)
and f1.read() == f2.read())
if not same:
raise SystemExit(
'還原之後 %s 跟備份對不起來 —— 請不要繼續用這個檔,\n'
' 改用你自己另外留的那一份備份。' % path)
print(' 已還原 %s(逐位元組複驗過,跟備份相同)\n' % os.path.basename(path))
return 0
def _seg_no(text):
"""段號一定是非負整數。打錯的話給一句中文,不要讓 int() 丟 ValueError 的
traceback —— 這一課的讀者看不懂那個。"""
try:
n = int(text)
except ValueError:
raise DataError('段號要填數字,你填的是「%s」' % text)
if n < 0:
raise DataError('段號不能是負數(你填的是 %d)' % n)
return n
# 進入點。動作是互斥的,判斷的順序就是優先權:
# --restore > --list > --export > --import,什麼都不給就印檔頭。
# DataError 一律變成一行人看得懂的話 + exit code 1,不丟 traceback 給讀者看。
# (最常打錯的幾種情形 —— WAV 檔名打錯、丟了不是 WAV 的檔、段號打成非數字、
# 輸出資料夾不存在 —— 已經在 read_wav / _seg_no / _check_out_path 裡先變成
# DataError,所以走到這裡就都是一句中文,不會是 traceback。)
def main():
ap = argparse.ArgumentParser(description='MVP Baseball 2005 聲音解碼/編碼工具')
ap.add_argument('path', help='遊戲裡的聲音檔')
ap.add_argument('--info', action='store_true', help='看檔頭')
ap.add_argument('--list', action='store_true', help='列出裡面每一段')
ap.add_argument('--export', nargs=2, metavar=('段號', '輸出.wav'))
ap.add_argument('--import', nargs=2, metavar=('段號', '來源.wav'), dest='imp')
ap.add_argument('--apply', action='store_true', help='真的寫入(不加只做預覽)')
ap.add_argument('--restore', action='store_true', help='還原本工具做的備份')
a = ap.parse_args()
if not os.path.isfile(a.path):
print(' ❌ 找不到 %s' % a.path)
return 2
try:
if a.restore:
return cmd_restore(a.path)
if a.list:
return cmd_list(a.path)
if a.export:
return cmd_export(a.path, _seg_no(a.export[0]), a.export[1])
if a.imp:
return cmd_import(a.path, _seg_no(a.imp[0]), a.imp[1], a.apply)
return cmd_info(a.path)
except DataError as err:
print(' ❌ %s' % err)
return 1
# Ctrl-C 之後要跟讀者說的那句話。看的是 _LIVE_REPLACED 這個旗標
# (os.replace 真的做過才會是 True),不是用猜的 —— 猜錯的兩個方向都很貴:
# 說「沒動到」而其實動到了,他就不會去還原;說「動到了」而其實沒有,
# 他會白跑一次 --restore(而那一次會因為找不到備份而失敗,更嚇人)。
def _interrupt_note():
if _LIVE_REPLACED:
return (' ⚠️ 已中斷,而且遊戲檔**已經被換成新的內容了**。\n'
' 要回到原樣就跑:--restore')
# 第三態:換名已經開始、還沒有結論。正常情況走不到這裡(_NoInterrupt 會把
# Ctrl-C 押到登記之後),但裝不上處理器的時候它就是保險 —— 這種時候寧可說
# 「不確定」也不可以說「沒動到」。
if _LIVE_REPLACING:
return (' ⚠️ 已中斷,而中斷的時候正在替換 %s ——\n'
' 換成功了沒有,本工具不敢保證。請跑 --restore 還原,\n'
' 或自己拿 %s 比對。' % (_LIVE_REPLACING, BACKUP_SUFFIX))
return ' 已中斷。遊戲檔一個位元組都沒有動到。'
# ─────────────────────────────────────────────────────────
# 自我測試(--selftest):不碰任何遊戲檔,全部在系統暫存資料夾裡做
# 每一道守門都下一個餌 —— 沒有反向測試的檢查,分不出「沒問題」跟「根本沒跑」。
# ─────────────────────────────────────────────────────────
def selftest():
global _LIVE_REPLACED
# ⚠️ 這一關要排在最前面:python -O 會把整支程式裡的 assert 全部拿掉,
# 下面每一個餌都是靠 assert 判定的,少了它們這支測試會一路印「全部通過」
# 卻什麼都沒有驗到 —— 那比沒有測試更危險。寧可拒跑。
if sys.flags.optimize:
print('--selftest 不能在 python -O 下跑:-O 會把 assert 全部拿掉,測試會假綠')
return 2
baits = 0
dirs = []
def _new_dir():
d = tempfile.mkdtemp(prefix='mvp_audio_selftest-')
dirs.append(d)
return d
def _put(path, data):
with open(path, 'wb') as f:
f.write(data)
return path
def _get(path):
with open(path, 'rb') as f:
return f.read()
def _expect(exc, fn, what):
try:
fn()
except exc:
return
except Exception as err:
raise AssertionError('%s:丟出來的是 %r,不是 %s'
% (what, err, exc.__name__))
raise AssertionError('%s:竟然沒有被擋下來' % what)
try:
# ── 一、目的地是符號連結就拒絕 ───────────────────────
# 餌:備份檔那個名字先被換成一個指向資料夾外面的連結。
# 沒有這一道的話,備份會沿著它把外面那個檔覆蓋掉。
d = _new_dir()
outside = _put(os.path.join(d, 'OUTSIDE.bin'), b'DO NOT TOUCH')
box = os.path.join(d, 'box')
os.mkdir(box)
live = _put(os.path.join(box, 'g.dat'), b'LIVE' * 64)
bak = os.path.join(box, 'g.dat' + BACKUP_SUFFIX)
os.symlink(outside, bak)
_expect(DataError, lambda: _atomic_copy(live, bak), '備份檔是符號連結')
baits += 1
assert _get(outside) == b'DO NOT TOUCH', '資料夾外面那個檔被動到了'
# ── 二、暫存檔的名字不可以是猜得到的 ─────────────────
# 餌:把三個「以前用過的」固定名字全部先佔成指向外面的連結。
# 舊版是 open('<目的檔>.part', 'wb'),那一行會沿著連結把外面截成 0。
os.remove(bak)
planted = ['g.dat.part', 'g.dat' + BACKUP_SUFFIX + '.part', 'g.dat.tmp']
for name in planted:
os.symlink(outside, os.path.join(box, name))
_atomic_copy(live, bak) # 陰性對照:正常流程要成功
baits += len(planted)
assert _get(bak) == _get(live), '備份的內容不對(陰性對照沒過)'
assert _get(outside) == b'DO NOT TOUCH', '猜得到的暫存名字把外面的檔寫壞了'
for name in planted:
assert os.path.islink(os.path.join(box, name)), '%s 被當成一般檔用掉了' % name
left = sorted(os.listdir(box))
assert left == sorted(['g.dat', 'g.dat' + BACKUP_SUFFIX] + planted), \
'資料夾裡留下了不該有的東西:%s' % left
# ── 三、還原是原子的:換名失敗,正本要原封不動 ────────
# 餌:把 os.replace 換成一個一定會爆的函式。
# 舊版是 shutil.copy2(備份, 正本),正本在那一刻就已經被截成 0 了。
d2 = _new_dir()
live2 = _put(os.path.join(d2, 'g.dat'), b'NEW' * 100)
bak2 = _put(os.path.join(d2, 'g.dat' + BACKUP_SUFFIX), b'OLD' * 100)
def _boom(*a, **k):
raise RuntimeError('餌:故意讓換名失敗')
_LIVE_REPLACED = False
real_replace = os.replace
os.replace = _boom
try:
_expect(RuntimeError, lambda: _do_copy(bak2, live2), '換名失敗')
finally:
os.replace = real_replace
baits += 1
assert _get(live2) == b'NEW' * 100, '換名失敗,正本卻已經被動過了'
assert _LIVE_REPLACED is False, '換名失敗,旗子卻沒有放下來'
assert sorted(os.listdir(d2)) == sorted(['g.dat', 'g.dat' + BACKUP_SUFFIX]), \
'失敗之後留下了暫存檔沒收掉'
# ── 四、還原前的逐位元組比對 ─────────────────────────
# 餌:讓複製只寫進去三個位元組。長度對不上就必須拒絕換上去。
def _short(fin, fout, length=0):
fout.write(fin.read()[:3])
real_cfo = shutil.copyfileobj
shutil.copyfileobj = _short
try:
_expect(DataError, lambda: _do_copy(bak2, live2), '還原的內容對不起來')
finally:
shutil.copyfileobj = real_cfo
baits += 1
assert _get(live2) == b'NEW' * 100, '比對沒過,正本卻已經被換掉了'
# 陰性對照:上面兩個餌拿掉之後,還原本身要真的會動作
_LIVE_REPLACED = False
_do_copy(bak2, live2)
assert _get(live2) == b'OLD' * 100, '正常的還原沒有把備份換上去'
assert _LIVE_REPLACED is True, '換過正本卻沒記下來(Ctrl-C 會說錯話)'
# ── 五、寫完之後的複驗 ───────────────────────────────
d3 = _new_dir()
live3 = _put(os.path.join(d3, 'g.dat'), b'BAD!' * 50)
bak3 = _put(os.path.join(d3, 'g.dat' + BACKUP_SUFFIX), b'GOOD' * 50)
_verify_written(live3, b'BAD!' * 50, bak3) # 陰性對照:相符就安靜通過
# 餌:磁碟上的內容跟「本來要寫的」不一樣 —— 必須非 0 收場而且自動還原
_expect(SystemExit,
lambda: _verify_written(live3, b'WANT' * 50, bak3), '複驗對不起來')
baits += 1
assert _get(live3) == b'GOOD' * 50, '複驗沒過卻沒有自動還原'
# ── 六、備份本身壞掉的那兩道(既有守門,一起下餌)──────
d4 = _new_dir()
live4 = _put(os.path.join(d4, 'g.dat'), b'x' * 1000)
bak4 = _put(os.path.join(d4, 'g.dat' + BACKUP_SUFFIX), b'')
_expect(SystemExit,
lambda: _restore_from_backup(bak4, live4), '0 bytes 的備份')
baits += 1
_put(bak4, b'x' * 100) # 不到正本的一半
_expect(SystemExit,
lambda: _restore_from_backup(bak4, live4), '只有正本一成的備份')
baits += 1
assert _get(live4) == b'x' * 1000, '壞掉的備份還是把正本蓋掉了'
# ── 七、--export 的輸出檔名守門 ──────────────────────
d5 = _new_dir()
prec = _put(os.path.join(d5, 'precious.txt'), b'14 bytes here!')
src5 = _put(os.path.join(d5, 'g.dat'), b'SCHl')
link5 = os.path.join(d5, 'out.wav')
os.symlink(prec, link5)
_expect(DataError, lambda: _check_out_path(src5, link5), '輸出檔是符號連結')
baits += 1
assert _get(prec) == b'14 bytes here!', '輸出沿著連結把外面那個檔寫壞了'
# 餌:指向不存在的檔的連結。os.path.exists() 對它回 False,
# 只看 exists 的話這一關會整個被跳過。
dang = os.path.join(d5, 'dangling.wav')
os.symlink(os.path.join(d5, 'nope'), dang)
assert os.path.exists(dang) is False, '這個餌的前提不成立'
_expect(DataError,
lambda: _check_out_path(src5, dang), '指向不存在的檔的符號連結')
baits += 1
# 既有那道:已經存在而且不是 WAV
_expect(DataError, lambda: _check_out_path(src5, prec), '已經存在的非 WAV 檔')
baits += 1
_check_out_path(src5, os.path.join(d5, 'ok.wav')) # 陰性對照:新檔名要放行
# ── 八、Ctrl-C 說的話要跟旗標走 ──────────────────────
_LIVE_REPLACED = False
assert '一個位元組都沒有動到' in _interrupt_note(), '沒換過卻沒說沒動到'
_note_live_replaced()
assert '已經被換成新的內容' in _interrupt_note(), '換過了卻說什麼都沒動到'
baits += 1
_LIVE_REPLACED = False
# 第三態:停在「正在換」的中間 —— 這種時候只能說不確定,不可以說沒動到。
_note_replacing(os.path.join('someplace', 'g.dat'))
assert '正在替換' in _interrupt_note(), '停在換名中間卻說成一個位元組都沒動'
baits += 1
_note_replacing(None)
assert '一個位元組都沒有動到' in _interrupt_note(), \
'登記收掉之後應該回到「沒動到」'
# ── 九、編碼/解碼本身沒有被上面那些改動碰到 ──────────
import random
rnd = random.Random(20260905)
want = [rnd.randint(-8000, 8000) for _ in range(GROUP_SAMPLES)]
h, pay, p1, c1 = encode_group(want, 0, 0)[:4]
back, p2, c2 = decode_group(h, pay, 0, 0)
assert len(back) == GROUP_SAMPLES, '一組解出來的取樣數不對'
assert (p1, c1) == (p2, c2), '編碼與解碼收尾的狀態對不上'
assert (h >> 4) <= 3, '編碼吐出來的表頭高半位元組超出 0-3'
# ── 十、匯出的 WAV 也是「寫」,一樣不跟著符號連結走 ────
# _check_out_path 已經先看過一次,但「看過」跟「真的開檔」是兩個時刻。
# 餌:直接叫 write_wav 去寫一個指向資料夾外面的連結 —— 舊版的
# wave.open(path, 'wb') 會沿著它,把外面那個檔整個蓋成一個 WAV。
d6 = _new_dir()
keep6 = _put(os.path.join(d6, 'OUTSIDE.bin'), b'DO NOT TOUCH')
sub6 = os.path.join(d6, 'box')
os.mkdir(sub6)
link6 = os.path.join(sub6, 'out.wav')
os.symlink(keep6, link6)
_expect(DataError, lambda: write_wav(link6, [[0, 1, 2]], 22050),
'匯出的目的檔是符號連結')
baits += 1
assert _get(keep6) == b'DO NOT TOUCH', '匯出沿著連結把外面那個檔寫壞了'
assert os.path.islink(link6), '連結被當成一般檔用掉了'
# 陰性對照:正常的檔名要真的寫得出 WAV,而且不可以留下暫存檔
ok6 = os.path.join(sub6, 'ok.wav')
write_wav(ok6, [[0, 1, 2]], 22050)
assert _get(ok6)[:4] == b'RIFF', '正常的匯出沒有寫出 WAV(陰性對照沒過)'
assert sorted(os.listdir(sub6)) == ['ok.wav', 'out.wav'], \
'匯出之後留下了暫存檔:%s' % sorted(os.listdir(sub6))
# ── 十一、Ctrl-C 落在「換名成功」與「登記」中間 ────────
# 這正是本輪要修的那個窗口。餌:把 os.replace 換成「先真的換名,
# 再把當下裝著的 SIGINT 處理器叫起來」。有 _NoInterrupt 守著的話,
# 那一下只會被記著,登記照樣做得完;守門被拆掉的話,叫到的就是
# Python 預設的處理器,當場丟 KeyboardInterrupt,登記那一行永遠跑不到,
# 於是 Ctrl-C 的收尾會說「一個位元組都沒有動到」——而檔案已經換了。
d7 = _new_dir()
live7 = _put(os.path.join(d7, 'g.dat'), b'OLD' * 100)
fd7, tmp7 = _mkstemp_beside(live7, 'write')
with os.fdopen(fd7, 'wb') as f7:
f7.write(b'NEW' * 100)
def _replace_then_sigint(a, b):
real_replace(a, b)
h2 = signal.getsignal(signal.SIGINT)
if callable(h2):
h2(signal.SIGINT, None)
else:
raise KeyboardInterrupt
_LIVE_REPLACED = False
_note_replacing(None)
os.replace = _replace_then_sigint
try:
_expect(KeyboardInterrupt,
lambda: _replace_live(tmp7, live7), '換名成功之後才收到 Ctrl-C')
finally:
os.replace = real_replace
baits += 1
assert _get(live7) == b'NEW' * 100, '這個餌的前提不成立(檔案根本沒被換)'
assert _LIVE_REPLACED is True, \
'換名已經做完卻沒有登記 —— 收尾會說「一個位元組都沒有動到」'
assert _LIVE_REPLACING is None, '登記沒有收乾淨'
assert '已經被換成新的內容' in _interrupt_note(), '換過了收尾卻說錯話'
# 餌:換名**自己**失敗 —— 這時候「沒動到」才是真的,旗子不可以舉起來。
fd8, tmp8 = _mkstemp_beside(live7, 'write')
os.close(fd8)
_LIVE_REPLACED = False
os.replace = _boom
try:
_expect(RuntimeError, lambda: _replace_live(tmp8, live7), '換名自己失敗')
finally:
os.replace = real_replace
baits += 1
assert _LIVE_REPLACED is False, '換名失敗,旗子卻舉了起來'
assert _LIVE_REPLACING is None, '換名失敗,「正在換」的登記卻沒收掉'
assert '一個位元組都沒有動到' in _interrupt_note(), '換名失敗卻不敢說沒動到'
assert _get(live7) == b'NEW' * 100, '換名失敗,檔案卻被動過了'
os.remove(tmp8)
_LIVE_REPLACED = False
finally:
for d in dirs:
shutil.rmtree(d, ignore_errors=True)
print('自我測試:全部通過(含 %d 個反向餌)' % baits)
return 0
# ─────────────────────────────────────────────────────────
# 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.
# ─────────────────────────────────────────────────────────
if __name__ == '__main__':
# --selftest 要排在最前面判斷:它不需要遊戲檔,
# 走進 main() 反而會因為「沒給路徑」被 argparse 擋下來。
if '--selftest' in sys.argv:
sys.exit(selftest())
try:
sys.exit(main())
except KeyboardInterrupt:
# 離開碼一律 130(128 + SIGINT),**不可以是 0**:
# 0 會讓批次腳本、.bat、或包在外面的懶人包以為這一步做完了,繼續往下做。
print()
print(_interrupt_note())
print()
sys.exit(130)
接下來
- 拆開一個語音檔 —— 容器結構是怎麼量出來的
- 播報員為什麼不唸新球員的名字
- 播報與音訊系統 —— 13 個音訊分區
- 七分鐘版